From f4da4b54dac6422755dc123bd41750751d985d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Wed, 30 Sep 2026 13:43:14 -0300 Subject: [PATCH 1/4] Vendor cel4postgres 0.0.2 cel4postgres 0.0.2 ships its plain bundle without transaction control, so the vendored file can sit inside a transaction the caller owns -- the property fga4postgres's own bundles are about to need. The file is the published release asset, byte for byte: its sha256 matches the release's SHA256SUMS. --- scripts/release-engine-initdb.sh | 12 -- ...res--0.0.1.sql => cel4postgres--0.0.2.sql} | 128 +----------------- 2 files changed, 6 insertions(+), 134 deletions(-) delete mode 100755 scripts/release-engine-initdb.sh rename vendor/{cel4postgres--0.0.1.sql => cel4postgres--0.0.2.sql} (99%) diff --git a/scripts/release-engine-initdb.sh b/scripts/release-engine-initdb.sh deleted file mode 100755 index 340ce32..0000000 --- a/scripts/release-engine-initdb.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -# Release-smoke initdb sequencer: install the vendored cel4postgres -# bundle first, then the engine-only release artifact — the layered -# install the engine-only file is for. Used by the release -# workflow's smoke job only. -set -eu - -for f in /vendor/cel4postgres--*.sql /engine.sql; do - echo "release smoke: applying $f" - psql -v ON_ERROR_STOP=1 \ - --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$f" -done diff --git a/vendor/cel4postgres--0.0.1.sql b/vendor/cel4postgres--0.0.2.sql similarity index 99% rename from vendor/cel4postgres--0.0.1.sql rename to vendor/cel4postgres--0.0.2.sql index d783db3..ff370dc 100644 --- a/vendor/cel4postgres--0.0.1.sql +++ b/vendor/cel4postgres--0.0.2.sql @@ -6,11 +6,13 @@ -- the cel schema. Nothing here requires superuser, a filesystem, or -- a server restart: see CLAUDE.md, "Installation and privileges". -- --- Run with: psql -v ON_ERROR_STOP=1 -f sql/000_install.sql +-- Run with: psql -v ON_ERROR_STOP=1 -1 -f sql/000_install.sql -- followed by the other sql/ scripts in numbered order (or use a -- release artifact, which bundles them already ordered). - -BEGIN; +-- +-- No script opens a transaction of its own. The caller decides: +-- psql -1 (--single-transaction) per file, a migration tool's +-- transaction around a whole bundle, or pg_tle's CREATE EXTENSION. CREATE SCHEMA IF NOT EXISTS cel; @@ -24,7 +26,7 @@ CREATE TABLE IF NOT EXISTS cel.schema_version ( ); INSERT INTO cel.schema_version (version) -VALUES ('0.0.1') +VALUES ('0.0.2') ON CONFLICT (version) DO NOTHING; -- The installed schema version. IMMUTABLE is deliberately wrong for @@ -42,8 +44,6 @@ AS $$ LIMIT 1; $$; -COMMIT; - -- ---- sql/010_registry.sql ---- -- cel4postgres -- the four registries. @@ -54,8 +54,6 @@ COMMIT; -- core could reach anything the registry cannot describe, the -- registry would stop being the extension mechanism. -BEGIN; - -- Custom and well-known types: name resolution, construction, -- equality and conversion hooks. Every row visible in an env also -- implies an identifier of type type(T) under the type's name. @@ -231,8 +229,6 @@ INSERT INTO cel.env (name, flags) VALUES ('network', '{}') ON CONFLICT (name) DO NOTHING; -COMMIT; - -- ---- sql/020_values.sql ---- -- cel4postgres -- value helpers. @@ -240,8 +236,6 @@ COMMIT; -- Run after 000_install.sql. Everything here is a pure function over -- tagged jsonb values or their scalar payloads; nothing reads a table. -BEGIN; - -- Renders a finite double the way CEL's string(double) must: cel-go -- delegates to Go's %g (common/types/double.go:141, pinned v0.32.0). -- Postgres's own float8 output is close but measurably different in @@ -365,10 +359,6 @@ BEGIN END; $$; -COMMIT; - -BEGIN; - -- Tagged-value primitives. The kind tag carries type identity; -- these helpers are the single place equality, ordering and payload -- access are defined, so every impl and the evaluator agree on them. @@ -702,10 +692,6 @@ BEGIN END; $$; -COMMIT; - -BEGIN; - -- Exact float8 -> numeric. The built-in cast goes through the -- shortest decimal text, which identifies the double uniquely but is -- NOT its exact binary value (36028797018963968::float8::numeric @@ -744,8 +730,6 @@ BEGIN END; $$; -COMMIT; - -- ---- sql/030_parse.sql ---- -- cel4postgres -- lexer, parser, macro engine. @@ -760,8 +744,6 @@ COMMIT; -- lexical rules follow it exactly, including the newline -- normalization applied to every literal form. -BEGIN; - -- Lexes one string or bytes literal. pos points at the opening quote -- (prefixes r/R/b/B already consumed by the caller). Returns the -- decoded value: text for strings, base64 text for bytes. ni is the @@ -1178,10 +1160,6 @@ BEGIN END; $$; -COMMIT; - -BEGIN; - -- Line/column (both 0-based line, 0-based col) for an offset, for -- parse error reporting. Conformance never string-matches parse -- errors; this exists for humans. @@ -1370,10 +1348,6 @@ BEGIN END; $$; -COMMIT; - -BEGIN; - -- Hex digits to numeric (uint64-sized values overflow bigint). CREATE OR REPLACE FUNCTION cel._p_hex(h text) RETURNS numeric @@ -1522,10 +1496,6 @@ BEGIN END; $$; -COMMIT; - -BEGIN; - -- The recursive grammar. Every function shares one signature: -- (tk, p, id, d, mac, fl) -> (node, np, nid, err, ep) -- tk: token array; p: cursor; id: last assigned node id; d: depth @@ -2097,10 +2067,6 @@ BEGIN END; $$; -COMMIT; - -BEGIN; - -- List literal: '[' consumed. Trailing comma allowed. '?e' elements -- (optionals extension) record their indices under "opt". CREATE OR REPLACE FUNCTION cel._p_list( @@ -2500,10 +2466,6 @@ BEGIN END; $$; -COMMIT; - -BEGIN; - -- Macro expanders. Each has the registry signature -- (target jsonb, args jsonb, next_id bigint) -- -> (expr jsonb, next_id bigint, err text) @@ -2929,8 +2891,6 @@ BEGIN END; $$; -COMMIT; - -- ---- sql/040_check.sql ---- -- cel4postgres -- type checker. @@ -2951,8 +2911,6 @@ COMMIT; -- Errors fail fast: conformance asserts on check-failure existence, -- never on collecting several. -BEGIN; - -- Type formatting for error messages (checker/format.go, loosely). CREATE OR REPLACE FUNCTION cel._t_fmt(t jsonb) RETURNS text @@ -3381,10 +3339,6 @@ BEGIN END; $$; -COMMIT; - -BEGIN; - -- Fresh type variables and parameter instantiation. CREATE OR REPLACE FUNCTION cel._ck_collect_params(t jsonb) RETURNS text[] @@ -3698,10 +3652,6 @@ BEGIN END; $$; -COMMIT; - -BEGIN; - -- Literal kinds to checker types. CREATE OR REPLACE FUNCTION cel._ck_lit_type(v jsonb) RETURNS jsonb @@ -4283,8 +4233,6 @@ AS $$ SELECT cel.check(ast, env, NULL); $$; -COMMIT; - -- ---- sql/050_eval.sql ---- -- cel4postgres -- evaluator core. @@ -4299,8 +4247,6 @@ COMMIT; -- the attribute machinery, everything else EXECUTEs the row's impl. -- No CASE on a function name anywhere -- day-one invariant 1. -BEGIN; - -- Signature match for runtime overload selection: does an evaluated -- argument satisfy a declared argument type? Parameterized and -- dynamic types erase to "match anything" at runtime; containers @@ -5146,8 +5092,6 @@ BEGIN END; $$; -COMMIT; - -- ---- sql/060_stdlib.sql ---- -- cel4postgres -- standard library, part 1. @@ -5164,8 +5108,6 @@ COMMIT; -- with the three non-finite sentinels, Go-style truncated division -- and remainder. -BEGIN; - -- Integer (int64) checked arithmetic. Postgres numeric is exact, so -- overflow is a range check, not a wraparound. @@ -5728,10 +5670,6 @@ AS $$ SELECT args[1]; $$; -COMMIT; - -BEGIN; - -- Overload rows. Ids are cel-go's exactly (common/overloads); the -- checker binds them and conformance's type_deduction output depends -- on them. The absorbed ids carry NULL impls -- the evaluator core @@ -5954,10 +5892,6 @@ INSERT INTO cel.env_item (env, kind, ref) SELECT 'standard', 'type', name FROM cel.type ON CONFLICT DO NOTHING; -COMMIT; - -BEGIN; - -- Part 2: type conversions and string functions. Conversion -- semantics are cel-go's exactly (common/types + overflow.go, -- measured): double-to-int excludes both 2^63 boundaries, string @@ -6434,8 +6368,6 @@ INSERT INTO cel.env_item (env, kind, ref) SELECT 'standard', 'overload', id FROM cel.overload ON CONFLICT DO NOTHING; -COMMIT; - -- ---- sql/070_wkt.sql ---- -- Well-known types: timestamps, durations, the wrapper types, @@ -6448,8 +6380,6 @@ COMMIT; -- Semantics are cel-go v0.32.0's (common/types/timestamp.go, -- duration.go, overflow.go), confirmed by conformance runs. -BEGIN; - -- Range-checked constructors ------------------------------------------ -- Seconds range is year 0001..9999 (timestamp.go:54-56); outside it @@ -6971,10 +6901,6 @@ AS $$ trunc((args[1] ->> 'v')::numeric / 1000000) % 1000); $$; -COMMIT; - -BEGIN; - -- Construction impls --------------------------------------------------- -- Each receives the evaluated fields as a jsonb object of tagged -- values (050_eval.sql struct branch). Wrappers unwrap to their @@ -7322,8 +7248,6 @@ INSERT INTO cel.env_item (env, kind, ref) SELECT 'standard', 'overload', id FROM cel.overload ON CONFLICT DO NOTHING; -COMMIT; - -- ---- sql/100_ext_comprehensions.sql ---- -- The two-variable comprehensions extension (cel-go @@ -7338,8 +7262,6 @@ COMMIT; -- because initdb runs only the directory's top level; a -- subdirectory would silently not install. -BEGIN; - -- Extracts and validates the two iteration variables -- (ext/comprehensions.go extractIterVars). CREATE OR REPLACE FUNCTION cel._mx2_vars( @@ -7684,8 +7606,6 @@ INSERT INTO cel.env_item (env, kind, ref) VALUES ('two_var_comprehensions', 'overload', '@mapInsert_map_map') ON CONFLICT DO NOTHING; -COMMIT; - -- ---- sql/110_ext_optionals.sql ---- -- The optionals extension, part one: the optional_type opaque type @@ -7701,8 +7621,6 @@ COMMIT; -- (day-one invariant 3: extension types are registry rows over the -- opaque kind, never new core kinds). -BEGIN; - CREATE OR REPLACE FUNCTION cel._opt_of(v jsonb) RETURNS jsonb LANGUAGE sql @@ -7843,10 +7761,6 @@ INSERT INTO cel.env_item (env, kind, ref) VALUES ('optionals', 'overload', 'optional_hasValue') ON CONFLICT DO NOTHING; -COMMIT; - -BEGIN; - -- Part two: the optional-syntax operators, or/orValue, and the -- optMap/optFlatMap macros (cel/library.go optionals block, -- cel/library.go:430-560 at the pinned v0.32.0). @@ -8184,8 +8098,6 @@ INSERT INTO cel.env_item (env, kind, ref) VALUES ('optionals', 'macro', 'optFlatMap/2/1') ON CONFLICT DO NOTHING; -COMMIT; - -- ---- sql/120_ext_strings.sql ---- -- The strings extension (cel-go ext/strings.go at the pinned @@ -8200,8 +8112,6 @@ COMMIT; -- offset error instead of returning -1 -- the corpus and cel-java -- agree against cel-go v0.32.0 there. -BEGIN; - CREATE OR REPLACE FUNCTION cel._str_val(s text) RETURNS jsonb LANGUAGE sql @@ -8541,10 +8451,6 @@ BEGIN END; $$; -COMMIT; - -BEGIN; - -- string.format ------------------------------------------------------ -- Round-half-even of an exact numeric at scale p, returned as a @@ -8986,8 +8892,6 @@ WHERE id IN ( 'string_format') ON CONFLICT DO NOTHING; -COMMIT; - -- ---- sql/130_ext_math.sql ---- -- The math extension (cel-go ext/math.go at the pinned v0.32.0, @@ -9000,8 +8904,6 @@ COMMIT; -- mod by exact powers of two) because Postgres bigint shifts take -- the count mod 64, and uint64 values do not fit bigint. -BEGIN; - CREATE OR REPLACE FUNCTION cel._math_ident(args jsonb[]) RETURNS jsonb LANGUAGE sql @@ -9640,16 +9542,12 @@ INSERT INTO cel.env_item (env, kind, ref) VALUES ('math', 'macro', 'greatest/-1/1') ON CONFLICT DO NOTHING; -COMMIT; - -- ---- sql/140_ext_lists.sql ---- -- The lists extension (cel-go ext/lists.go at the pinned v0.32.0): -- slice, flatten, sort, sortBy (macro over @sortByAssociatedKeys), -- lists.range, reverse, distinct. Registered under the 'lists' env. -BEGIN; - CREATE OR REPLACE FUNCTION cel._list_val(elems jsonb) RETURNS jsonb LANGUAGE sql @@ -10055,16 +9953,12 @@ INSERT INTO cel.env_item (env, kind, ref) VALUES ('lists', 'macro', 'sortBy/2/1') ON CONFLICT DO NOTHING; -COMMIT; - -- ---- sql/150_ext_encoders.sql ---- -- The encoders extension (cel-go ext/encoders.go at the pinned -- v0.32.0): base64.encode / base64.decode. Registered under the -- 'encoders' env. -BEGIN; - -- Go accepts both padded and raw (unpadded) standard base64 -- (encoders.go:143-150); Postgres decode requires padding, so pad -- first. @@ -10122,8 +10016,6 @@ INSERT INTO cel.env_item (env, kind, ref) VALUES ('encoders', 'overload', 'base64_encode_bytes') ON CONFLICT DO NOTHING; -COMMIT; - -- ---- sql/160_ext_bindings.sql ---- -- The bindings extension (cel-go ext/bindings.go at the pinned @@ -10131,8 +10023,6 @@ COMMIT; -- bind-style comprehension (empty range, accumulator = the bound -- variable). Registered under the 'bindings' env. -BEGIN; - CREATE OR REPLACE FUNCTION cel._mx_cel_bind( target jsonb, args jsonb, next_id bigint, OUT expr jsonb, OUT next_id_out bigint, OUT err text @@ -10191,8 +10081,6 @@ INSERT INTO cel.env_item (env, kind, ref) VALUES ('bindings', 'macro', 'bind/3/1') ON CONFLICT DO NOTHING; -COMMIT; - -- ---- sql/170_ext_network.sql ---- -- The network extension (cel-go ext/network.go at the pinned @@ -10210,8 +10098,6 @@ COMMIT; -- zone suffixes, no IPv4-mapped IPv6, and a CIDR requires an -- explicit /bits. -BEGIN; - -- Strict address parse. Returns the canonical text or NULL when the -- input is not a valid address under netip.ParseAddr rules. CREATE OR REPLACE FUNCTION cel._net_parse_ip(s text) @@ -10717,5 +10603,3 @@ INSERT INTO cel.env_item (env, kind, ref) VALUES ('network', 'type', 'net.CIDR') ON CONFLICT DO NOTHING; -COMMIT; - From ac0aa5b0fda8f40fc94c9221930a05cf9b9dbda6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Wed, 30 Sep 2026 13:43:14 -0300 Subject: [PATCH 2/4] Ship transaction-free bundles by default Every sql/ script opened and committed its own transaction, so the all-in bundle carried one BEGIN;/COMMIT; pair per script -- forty with the vendored cel4postgres. Embedding it in a migration tool, which runs each migration inside a transaction it owns, therefore failed in the worst way: the first COMMIT; ended the migration's transaction and the rest ran in autocommit, so a failure halfway left half an engine behind. pg_tle had the same problem, which is why pgtle-wrap.sh grepped those lines out. The sources now carry no transaction control and the plain artifacts are what the sources say. The caller picks the boundary: psql -1, a migration's transaction, or CREATE EXTENSION. Each artifact gets a -tx twin with exactly one BEGIN; and one COMMIT; around the whole file, for a bare psql -f that should still be all or nothing. The per-file install loops (initdb, the bench's persistent volume, the CI reinstall) now pass --single-transaction so each file stays atomic as before, and the bench loader already gets that from the simple protocol's implicit transaction. build-release.sh refuses a BEGIN; or COMMIT; line in sql/ or the vendored bundle. scripts/smoke-artifacts.sh, run by CI and by the release, installs each plain artifact between BEGIN and ROLLBACK and asserts nothing survives, which a stray COMMIT; would break; it replaces release-engine-initdb.sh. --- .github/workflows/ci.yml | 13 ++++-- .github/workflows/release.yml | 43 ++--------------- README.md | 16 ++++--- docker/000_install.sh | 7 ++- docs/INSTALL.md | 35 +++++++++++--- internal/bench/loader.go | 7 +-- scripts/build-release.sh | 49 +++++++++++++------ scripts/pgtle-wrap.sh | 15 ++++-- scripts/smoke-artifacts.sh | 88 +++++++++++++++++++++++++++++++++++ sql/010_install.sql | 8 ++-- sql/020_dialect.sql | 4 -- sql/030_store.sql | 4 -- sql/040_model.sql | 4 -- sql/050_tuple.sql | 4 -- sql/060_check.sql | 4 -- sql/070_list_objects.sql | 4 -- sql/080_list_users.sql | 4 -- sql/090_expand.sql | 4 -- sql/900_grants.sql | 4 -- 19 files changed, 199 insertions(+), 118 deletions(-) create mode 100755 scripts/smoke-artifacts.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4e98bc..c81f272 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,9 +56,9 @@ jobs: - name: Idempotent reinstall run: | DB="postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DB" - psql -v ON_ERROR_STOP=1 -q -f vendor/cel4postgres--*.sql "$DB" + psql -v ON_ERROR_STOP=1 -q -1 -f vendor/cel4postgres--*.sql "$DB" for f in sql/*.sql; do - psql -v ON_ERROR_STOP=1 -q -f "$f" "$DB" + psql -v ON_ERROR_STOP=1 -q -1 -f "$f" "$DB" done - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -87,8 +87,10 @@ jobs: run: docker compose logs --no-color # The release channels, proven on every run rather than only at - # publish time: the all-in bundle initdb-installs on the bare - # pinned image; the wrapped artifact installs through pg_tle via + # publish time: every artifact installs in its promised shape + # (plain inside a caller's transaction, -tx on its own); the + # all-in bundle initdb-installs on the bare pinned image; the + # wrapped artifact installs through pg_tle via # CREATE EXTENSION; and the grants template actually separates # the two roles (a reader can check but not write). artifacts: @@ -104,6 +106,9 @@ jobs: - name: Build artifacts run: ./scripts/build-release.sh + - name: Install every artifact in its promised shape + run: ./scripts/smoke-artifacts.sh + - name: Install the bundle on a bare database run: | version=$(sed -n "s/^VALUES ('\([0-9][0-9.]*\)');$/\1/p" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e768b2..b39bf75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,10 +18,10 @@ permissions: # between jobs -- fewer moving parts, and nothing to pin. jobs: - # The plain-scripts channel: the all-in bundle initdb-installs - # on the bare pinned image and answers fga.version(); the - # engine-only file installs over the vendored cel4postgres - # bundle the same way compose layers them. + # The plain-scripts channel: scripts/smoke-artifacts.sh installs + # every artifact in the shape it promises -- each plain file inside + # a transaction it does not own, each -tx file with a bare psql -f, + # the engine-only files over the vendored cel4postgres bundle. smoke: if: >- github.repository == 'emfga/fga4postgres' && @@ -38,40 +38,7 @@ jobs: - name: Build and install artifacts run: | ./scripts/build-release.sh - version=$(sed -n \ - "s/^VALUES ('\([0-9][0-9.]*\)');$/\1/p" \ - sql/010_install.sql) - image=$(sed -n 's/^ *image: \(postgres:[^ ]*\)$/\1/p' \ - compose.yaml) - smoke() { - docker run -d --rm --name relcheck \ - -e POSTGRES_DB=fga -e POSTGRES_USER=fga \ - -e POSTGRES_PASSWORD=pw "$@" \ - "$image" >/dev/null - # -h 127.0.0.1: over the socket, psql reaches - # initdb's temporary server mid-install; only the - # final server listens on TCP. - got= - for _ in $(seq 60); do - got=$(docker exec relcheck psql -h 127.0.0.1 \ - -U fga -d fga \ - -tAc 'SELECT fga.version()' 2>/dev/null) && break - sleep 2 - done - docker rm -f relcheck >/dev/null - if [ "$got" != "$version" ]; then - echo "expected $version, got '$got'" >&2 - exit 1 - fi - } - init=/docker-entrypoint-initdb.d - echo "== fga4postgres--$version.sql" - smoke -v "$PWD/dist/fga4postgres--$version.sql:$init/install.sql:ro" - echo "== fga4postgres-engine--$version.sql over vendored cel" - smoke \ - -v "$PWD/vendor:/vendor:ro" \ - -v "$PWD/dist/fga4postgres-engine--$version.sql:/engine.sql:ro" \ - -v "$PWD/scripts/release-engine-initdb.sh:$init/000.sh:ro" + ./scripts/smoke-artifacts.sh # The flagship channel: the all-in bundle wrapped by # scripts/pgtle-wrap.sh into pgtle.install_extension, installed diff --git a/README.md b/README.md index d242d31..e78d182 100644 --- a/README.md +++ b/README.md @@ -45,24 +45,26 @@ Distribution is plain SQL scripts and ## Installation -From a release, one file installs everything: +From a release, one file installs everything, in one transaction: ```bash -psql -v ON_ERROR_STOP=1 -f fga4postgres--.sql "$DB_URL" +psql -v ON_ERROR_STOP=1 -1 -f fga4postgres--.sql "$DB_URL" ``` From a checkout: ```bash -psql -v ON_ERROR_STOP=1 -f vendor/cel4postgres--*.sql "$DB_URL" +psql -v ON_ERROR_STOP=1 -1 -f vendor/cel4postgres--*.sql "$DB_URL" for f in sql/*.sql; do - psql -v ON_ERROR_STOP=1 -f "$f" "$DB_URL" + psql -v ON_ERROR_STOP=1 -1 -f "$f" "$DB_URL" done ``` -The scripts are idempotent; re-running the installer is the upgrade -path. [docs/INSTALL.md](docs/INSTALL.md) covers the pg_tle channel -and the consumer privilege template. +The scripts are idempotent and open no transaction of their own; +re-running the installer is the upgrade path. +[docs/INSTALL.md](docs/INSTALL.md) covers the `-tx` artifacts, +embedding in a migration tool, the pg_tle channel and the consumer +privilege template. ## Development environment diff --git a/docker/000_install.sh b/docker/000_install.sh index 21896d4..2d0b8a7 100755 --- a/docker/000_install.sh +++ b/docker/000_install.sh @@ -4,11 +4,14 @@ # fga4postgres script, in name order. This exists because initdb runs # only a flat directory and a single-file bind mount cannot be layered # over a read-only directory mount -- so sql/ and vendor/ are mounted -# at their own paths and this script sequences them. +# at their own paths and this script sequences them. The scripts +# open no transaction of their own, so --single-transaction keeps +# each file all or nothing -- which matters on the bench's +# persistent volume, not on compose's tmpfs. set -eu for f in /vendor/cel4postgres--*.sql /sql/*.sql; do echo "fga4postgres initdb: applying $f" - psql -v ON_ERROR_STOP=1 \ + psql -v ON_ERROR_STOP=1 --single-transaction \ --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$f" done diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f87dd4c..290dc04 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -12,10 +12,10 @@ PostgreSQL 18 is the floor: the engine uses the native ## Plain SQL (works everywhere) Download `fga4postgres--.sql` from a release (verify -against `SHA256SUMS`) and run it: +against `SHA256SUMS`) and run it in one transaction: ```sh -psql -v ON_ERROR_STOP=1 -f fga4postgres--.sql "$DB_URL" +psql -v ON_ERROR_STOP=1 -1 -f fga4postgres--.sql "$DB_URL" ``` That single file bundles the pinned cel4postgres release and the @@ -23,22 +23,45 @@ whole engine. If the database already runs the pinned cel4postgres, use `fga4postgres-engine--.sql` instead — it contains only the engine and expects schema `cel` to exist. +Each file comes in two shapes. The plain file contains no +transaction control, so it runs inside a transaction someone else +opens: psql's `-1` (`--single-transaction`), a migration tool's, or +pg_tle's `CREATE EXTENSION`. Its `-tx` twin +(`fga4postgres-tx--.sql`, +`fga4postgres-engine-tx--.sql`) wraps the same content in +exactly one `BEGIN;` and one `COMMIT;`, for a bare `psql -f` that +should still be all or nothing. + Every script is idempotent; re-running the installer against a live database is the upgrade path. From a checkout instead of a release: ```sh -psql -v ON_ERROR_STOP=1 -f vendor/cel4postgres--*.sql "$DB_URL" +psql -v ON_ERROR_STOP=1 -1 -f vendor/cel4postgres--*.sql "$DB_URL" for f in sql/*.sql; do - psql -v ON_ERROR_STOP=1 -f "$f" "$DB_URL" + psql -v ON_ERROR_STOP=1 -1 -f "$f" "$DB_URL" done ``` +## Inside a migration tool + +Migration tools (Flyway, Liquibase, Kysely, Alembic, Rails, …) +usually run each migration inside a transaction they own. Embed the +plain `fga4postgres--.sql` as the body of one migration +and execute it as a single multi-statement string, without bind +parameters — the file is many statements with `$$`-quoted function +bodies, which PostgreSQL accepts only over the simple query +protocol. The whole install then commits or rolls back with the +migration. Do not embed a `-tx` file: its `COMMIT;` would end the +tool's transaction halfway through. + ## pg_tle (RDS, Aurora, and anywhere pg_tle is allowed) -Wrap the release artifact into a `pgtle.install_extension` call -and install it as a real extension: +Wrap the plain release artifact (not a `-tx` one: transaction +control is not allowed inside `CREATE EXTENSION`, and the script +refuses it) into a `pgtle.install_extension` call and install it +as a real extension: ```sh ./scripts/pgtle-wrap.sh fga4postgres \ diff --git a/internal/bench/loader.go b/internal/bench/loader.go index eb36b56..60d7810 100644 --- a/internal/bench/loader.go +++ b/internal/bench/loader.go @@ -69,9 +69,10 @@ func installFiles(root string) ([]string, error) { return append(cel, engine...), nil } -// execFile runs one install script. The scripts hold their own -// BEGIN/COMMIT and multiple statements, so this goes through the -// simple protocol. +// execFile runs one install script. A script is many statements, +// so this goes through the simple protocol, which also runs the +// whole string as one implicit transaction: the scripts open none +// of their own, and a failure leaves the file unapplied. func execFile( ctx context.Context, pool *pgxpool.Pool, path string, ) error { diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 2fe0ddc..6762ede 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -6,16 +6,21 @@ # than keeping a copy that could drift (CLAUDE.md: no version # copies). # -# Two shapes, because plain SQL scripts and pg_tle are both -# first-class distribution channels: -# the all-in bundle inlines the vendored cel4postgres release -# so one `psql -f` installs everything on a bare database; the -# engine-only file serves databases that already run the pinned -# cel4postgres. Plain concatenation is safe because every script -# opens and closes its own transaction. Install with -# ON_ERROR_STOP so a failure stops between them: +# Two bundles, because plain SQL scripts and pg_tle are both +# first-class distribution channels: the all-in bundle inlines the +# vendored cel4postgres release so one file installs everything on a +# bare database; the engine-only file serves databases that already +# run the pinned cel4postgres. # -# psql -v ON_ERROR_STOP=1 -f fga4postgres--.sql +# Each comes in two shapes. The plain file carries no transaction +# control, so it runs inside whatever transaction the caller already +# holds -- a migration tool's, pg_tle's CREATE EXTENSION, or psql's +# --single-transaction: +# +# psql -v ON_ERROR_STOP=1 -1 -f fga4postgres--.sql +# +# The -tx file is the same bundle between exactly one BEGIN; and one +# COMMIT;, for a plain `psql -f` that should still be all or nothing. set -eu @@ -40,27 +45,45 @@ case $(printf '%s\n' "$vendored" | wc -l) in ;; esac +# Neither the sources nor the vendored cel4postgres bundle may open +# or close a transaction; that is what lets the plain artifacts run +# inside a caller's transaction. A BEGIN; or COMMIT; line would +# silently commit that transaction halfway, so refuse to build +# rather than ship it. +# shellcheck disable=SC2086 +if grep -n -x -e 'BEGIN;' -e 'COMMIT;' "$vendored" sql/*.sql; then + echo "install scripts must not open or close transactions" >&2 + exit 1 +fi + rm -rf dist mkdir -p dist # Concatenate the named files, each behind a banner naming its # source, so an error line in a bundle is traceable to a script. +# Writes --.sql and its -tx--.sql +# twin. bundle() { - out=$1 + name=$1 shift for f in "$@"; do printf -- '-- ---- %s ----\n\n' "$f" cat "$f" printf '\n' - done >"dist/$out" + done >"dist/$name--$version.sql" + { + printf 'BEGIN;\n\n' + cat "dist/$name--$version.sql" + printf 'COMMIT;\n' + } >"dist/$name-tx--$version.sql" } engine=$(ls sql/*.sql) # shellcheck disable=SC2086 -bundle "fga4postgres--$version.sql" "$vendored" $engine +bundle fga4postgres "$vendored" $engine # shellcheck disable=SC2086 -bundle "fga4postgres-engine--$version.sql" $engine +bundle fga4postgres-engine $engine (cd dist && sha256sum -- *.sql >SHA256SUMS) diff --git a/scripts/pgtle-wrap.sh b/scripts/pgtle-wrap.sh index a36c7b6..53af081 100755 --- a/scripts/pgtle-wrap.sh +++ b/scripts/pgtle-wrap.sh @@ -6,10 +6,9 @@ # # pgtle-wrap.sh ... > out.sql # -# The artifacts run unchanged except for one transformation: their -# top-level BEGIN;/COMMIT; lines are dropped, because a pg_tle -# script executes inside CREATE EXTENSION's own transaction, where -# transaction control is not allowed. +# The artifacts run unchanged. Pass the plain ones, never a -tx +# twin: a pg_tle script executes inside CREATE EXTENSION's own +# transaction, where transaction control is not allowed. set -eu @@ -21,7 +20,13 @@ tag='_fga_pgtle_' tmp=$(mktemp) trap 'rm -f "$tmp"' EXIT -cat -- "$@" | grep -v -x -e 'BEGIN;' -e 'COMMIT;' >"$tmp" +cat -- "$@" >"$tmp" + +if grep -q -x -e 'BEGIN;' -e 'COMMIT;' "$tmp"; then + echo "input controls transactions; pass the plain artifacts," \ + "not the -tx ones" >&2 + exit 1 +fi # The bundle becomes one dollar-quoted literal; the quoting breaks # if its tag ever appears in the content, so refuse instead of diff --git a/scripts/smoke-artifacts.sh b/scripts/smoke-artifacts.sh new file mode 100755 index 0000000..0fe2820 --- /dev/null +++ b/scripts/smoke-artifacts.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env sh +# Install every artifact in dist/ on a bare database of the pinned +# image, in the shape each one promises: +# +# - a plain artifact runs inside a transaction it does not own. +# Installed between BEGIN and ROLLBACK it must leave no fga or cel +# schema behind -- one stray COMMIT; would keep them -- and +# installed with --single-transaction it must answer fga.version(); +# - a -tx artifact installs with a bare psql -f. +# +# The engine-only files install over the vendored cel4postgres +# bundle, in the same transaction. Run after build-release.sh; used +# by the CI and release workflows. + +set -eu + +cd "$(dirname "$0")/.." + +version=$(sed -n "s/^VALUES ('\([0-9][0-9.]*\)');$/\1/p" \ + sql/010_install.sql) +image=$(sed -n 's/^ *image: \(postgres:[^ ]*\)$/\1/p' compose.yaml) +cel=$(basename "$(ls vendor/cel4postgres--*.sql)") + +docker run -d --rm --name smoke \ + -e POSTGRES_DB=fga -e POSTGRES_USER=fga -e POSTGRES_PASSWORD=pw \ + -v "$PWD/dist:/dist:ro" -v "$PWD/vendor:/vendor:ro" \ + "$image" >/dev/null +trap 'docker rm -f smoke >/dev/null' EXIT + +# -h 127.0.0.1: over the socket, pg_isready can reach initdb's +# temporary server; only the final one listens on TCP. +for _ in $(seq 60); do + docker exec smoke pg_isready -h 127.0.0.1 -U fga -d fga -q \ + 2>/dev/null && break + sleep 2 +done + +sql() { + docker exec -e PGOPTIONS='-c client_min_messages=warning' \ + smoke psql -h 127.0.0.1 -U fga -d fga -v ON_ERROR_STOP=1 -q "$@" +} + +reset() { + sql -c 'DROP SCHEMA IF EXISTS fga CASCADE' \ + -c 'DROP SCHEMA IF EXISTS cel CASCADE' +} + +expect_version() { + got=$(sql -tAc 'SELECT fga.version()') + if [ "$got" != "$version" ]; then + echo "expected $version, got '$got'" >&2 + exit 1 + fi + reset +} + +# $1 names the bundle; the rest are the files installed before it. +smoke() { + name=$1 + shift + pre= + for f in "$@"; do + pre="$pre -f $f" + done + + plain="/dist/$name--$version.sql" + echo "== $plain" + # shellcheck disable=SC2086 + sql -c 'BEGIN' $pre -f "$plain" -c 'ROLLBACK' + left=$(sql -tAc "SELECT count(*) FROM pg_namespace + WHERE nspname IN ('fga', 'cel')") + if [ "$left" != 0 ]; then + echo "$plain committed on its own" >&2 + exit 1 + fi + # shellcheck disable=SC2086 + sql --single-transaction $pre -f "$plain" + expect_version + + tx="/dist/$name-tx--$version.sql" + echo "== $tx" + # shellcheck disable=SC2086 + sql $pre -f "$tx" + expect_version +} + +smoke fga4postgres +smoke fga4postgres-engine "/vendor/$cel" diff --git a/sql/010_install.sql b/sql/010_install.sql index e7a4fc7..ed1db17 100644 --- a/sql/010_install.sql +++ b/sql/010_install.sql @@ -3,8 +3,10 @@ -- 010 is the first fga4postgres script on purpose: in initdb the 000 -- slot is taken by the vendored cel4postgres bundle (see compose.yaml), -- which must install before anything here runs. - -BEGIN; +-- +-- No script opens a transaction of its own. The caller decides: +-- psql -1 (--single-transaction) per file, a migration tool's +-- transaction around a whole bundle, or pg_tle's CREATE EXTENSION. CREATE SCHEMA IF NOT EXISTS fga; @@ -28,5 +30,3 @@ SET search_path = fga, pg_temp AS $$ SELECT version FROM fga.schema_version; $$; - -COMMIT; diff --git a/sql/020_dialect.sql b/sql/020_dialect.sql index 778121b..b248063 100644 --- a/sql/020_dialect.sql +++ b/sql/020_dialect.sql @@ -16,8 +16,6 @@ -- actually references it — which matches upstream's -- partial-evaluation behaviour without needing UNKNOWN support. -BEGIN; - INSERT INTO cel.env (name) VALUES ('openfga') ON CONFLICT DO NOTHING; @@ -397,5 +395,3 @@ BEGIN END IF; END; $$; - -COMMIT; diff --git a/sql/030_store.sql b/sql/030_store.sql index dc2d45a..61dee4f 100644 --- a/sql/030_store.sql +++ b/sql/030_store.sql @@ -8,8 +8,6 @@ -- "latest" queries stay index-backed. PostgreSQL 18 is the version -- floor precisely because uuidv7() is used natively, with no shim. -BEGIN; - CREATE TABLE IF NOT EXISTS fga.store ( id uuid NOT NULL DEFAULT uuidv7(), name text NOT NULL, @@ -58,5 +56,3 @@ BEGIN DELETE FROM fga.store WHERE id = store_id; END; $$; - -COMMIT; diff --git a/sql/040_model.sql b/sql/040_model.sql index 52ccdbc..b4820b7 100644 --- a/sql/040_model.sql +++ b/sql/040_model.sql @@ -11,8 +11,6 @@ -- fga._validate_model below; the derivation queries themselves -- assume upstream-shaped input and only normalize. -BEGIN; - -- The id-domain gate: every id the API accepts must be a -- canonical lower-case hyphenated uuid, and the -- nil uuid is reserved as the wildcard sentinel. Everything else @@ -629,5 +627,3 @@ BEGIN RETURN resolved; END; $$; - -COMMIT; diff --git a/sql/050_tuple.sql b/sql/050_tuple.sql index 2cb0952..1bfa70c 100644 --- a/sql/050_tuple.sql +++ b/sql/050_tuple.sql @@ -8,8 +8,6 @@ -- (condition deliberately not part of tuple identity) and keeps -- REPLICA IDENTITY intact for logical replication. -BEGIN; - CREATE TABLE IF NOT EXISTS fga.tuple ( store uuid NOT NULL, object_type text NOT NULL, @@ -945,5 +943,3 @@ AS $$ AND tr.condition_name = coalesce(t.condition_name, '') ); $$; - -COMMIT; diff --git a/sql/060_check.sql b/sql/060_check.sql index 29ee6c6..0662db2 100644 --- a/sql/060_check.sql +++ b/sql/060_check.sql @@ -32,8 +32,6 @@ -- object form, object type, request relation; contextual -- tuples last, refused as YF127. -BEGIN; - DO $$ BEGIN CREATE TYPE fga._check_result AS (allowed boolean, @@ -741,5 +739,3 @@ BEGIN RETURN jsonb_build_object('result', results); END; $$; - -COMMIT; diff --git a/sql/070_list_objects.sql b/sql/070_list_objects.sql index 8ba969c..f18155c 100644 --- a/sql/070_list_objects.sql +++ b/sql/070_list_objects.sql @@ -26,8 +26,6 @@ -- forward check validates the entire path, so optimistic -- expansion through such relations is sound. -BEGIN; - DO $$ BEGIN CREATE TYPE fga._lo_node AS ( @@ -415,5 +413,3 @@ BEGIN to_jsonb(objects)); END; $$; - -COMMIT; diff --git a/sql/080_list_users.sql b/sql/080_list_users.sql index 6cfba14..4b3c072 100644 --- a/sql/080_list_users.sql +++ b/sql/080_list_users.sql @@ -25,8 +25,6 @@ -- errors at 26 — list_users charges one hop more; cycles -- yield nothing (fail-closed). -BEGIN; - DO $$ BEGIN -- uid = nil uuid and urel = '' means the typed wildcard; @@ -582,5 +580,3 @@ AS $$ AND tr.condition_name = coalesce(t.condition_name, '') ); $$; - -COMMIT; diff --git a/sql/090_expand.sql b/sql/090_expand.sql index 02e384d..8408bb7 100644 --- a/sql/090_expand.sql +++ b/sql/090_expand.sql @@ -12,8 +12,6 @@ -- write (ulid) order, matching upstream's tuple order; the -- differential comparison is order-insensitive over both. -BEGIN; - CREATE OR REPLACE FUNCTION fga._expand_node( store_id uuid, model_id uuid, ot text, oid uuid, rel text, @@ -177,5 +175,3 @@ BEGIN store_id, mid, o.object_type, oid, rel, name, ctx, rw))); END; $$; - -COMMIT; diff --git a/sql/900_grants.sql b/sql/900_grants.sql index 6cd8389..e0f3181 100644 --- a/sql/900_grants.sql +++ b/sql/900_grants.sql @@ -15,8 +15,6 @@ -- CALLER rights — no SECURITY DEFINER — so table privileges are -- part of the reader surface, not a bypass of it. -BEGIN; - DO $$ BEGIN IF NOT EXISTS ( @@ -87,5 +85,3 @@ BEGIN ON ALL TABLES IN SCHEMA fga TO fga_writer; END; $$; - -COMMIT; From 825cd05cdae7a6fda24e093c63eee8147ae769a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Wed, 30 Sep 2026 13:43:14 -0300 Subject: [PATCH 3/4] Number the next release 0.0.2 The artifact set changed shape: plain files lost their transaction control and -tx twins were added. A consumer pinning 0.0.1 must not receive files that behave differently under the same name. --- sql/010_install.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/010_install.sql b/sql/010_install.sql index ed1db17..2f094a1 100644 --- a/sql/010_install.sql +++ b/sql/010_install.sql @@ -19,7 +19,7 @@ TRUNCATE fga.schema_version; -- The version lives here and only here; the release build and the -- workflows sed it out of this file rather than keeping a copy. INSERT INTO fga.schema_version -VALUES ('0.0.1'); +VALUES ('0.0.2'); -- STABLE, not IMMUTABLE: it reads a table. CREATE OR REPLACE FUNCTION fga.version() From 9b0f746c01544af0eb2d57ac06019422b9d24a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lemuel=20Roberto=20Bonif=C3=A1cio?= Date: Wed, 30 Sep 2026 13:43:14 -0300 Subject: [PATCH 4/4] Record that install scripts own no transaction The sql/ naming section justified plain concatenation by every script opening its own BEGIN;/COMMIT;. That is no longer true, and a contributor following it would reintroduce exactly the lines that stop a bundle being embedded in a caller's transaction. State the new contract and what enforces it, and point the by-hand install and the vendoring decision at cel4postgres 0.0.2. --- CLAUDE.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6b7ac7c..5f0f314 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ a future contributor will otherwise reopen. verbatim JSON per model id plus a normalized internal form for fast resolution. Owner-confirmed. 4. **CEL is consumed from cel4postgres, never reimplemented.** The - pinned release artifact (`vendor/cel4postgres--0.0.1.sql`, from + pinned release artifact (`vendor/cel4postgres--0.0.2.sql`, from github.com/emfga/cel4postgres) installs before `sql/` in initdb and in the release story. Conditions evaluate through `cel.*`; the OpenFGA dialect (`ipaddress`, `in_cidr`, typed-parameter coercion) @@ -149,10 +149,16 @@ gitignored `dist/`. simultaneously install order and initdb order (initdb does not descend into subdirectories). fga4postgres scripts start at `010` because the `000` slot belongs to the vendored cel4postgres bundle, bind-mounted as -a single file in compose.yaml. Every script opens its own `BEGIN;` … -`COMMIT;` and is idempotent (`CREATE OR REPLACE`, `IF NOT EXISTS`, -`ON CONFLICT`), so plain concatenation builds a release and re-running -the installer is the upgrade path. +a single file in compose.yaml. No script opens or closes a +transaction — the caller owns the boundary (`psql -1`, a migration +tool's transaction, pg_tle's `CREATE EXTENSION`), which is what lets +a plain release artifact be embedded where a transaction is already +open; `build-release.sh` refuses a `BEGIN;`/`COMMIT;` line in `sql/` +or the vendored bundle, and each artifact's `-tx` twin adds exactly +one pair around the whole file. Every script is idempotent +(`CREATE OR REPLACE`, `IF NOT EXISTS`, `ON CONFLICT`), so plain +concatenation builds a release and re-running the installer is the +upgrade path. **Schema naming.** Everything lives in schema `fga`. Public entry points are unprefixed (`fga.check`, `fga.version`); every internal @@ -241,8 +247,8 @@ docker compose logs --no-color # on failure Installing by hand into any database: ```bash -psql -v ON_ERROR_STOP=1 -f vendor/cel4postgres--0.0.1.sql "$DB_URL" -for f in sql/*.sql; do psql -v ON_ERROR_STOP=1 -f "$f" "$DB_URL"; done +psql -v ON_ERROR_STOP=1 -1 -f vendor/cel4postgres--0.0.2.sql "$DB_URL" +for f in sql/*.sql; do psql -v ON_ERROR_STOP=1 -1 -f "$f" "$DB_URL"; done ``` The engine version lives in exactly one place: the