Contents
8. Operations
Install
Build and install the extension against the target PostgreSQL instance:
make
sudo make install
Validated PostgreSQL versions: 14, 15, 16, 17, and 18.
Then create the extension in the target database:
create extension pg_liquid;
This installs:
- the
liquidschema liquid.verticesliquid.edgesliquid.row_normalizersliquid.row_normalizer_bindingsliquid.query(text)liquid.query_as(text, text)liquid.read_as(text, text)- the row normalizer management functions
Upgrade
pg_liquid ships upgrade paths from the legacy internal versions 1.0.0,
1.1.0, and 1.2.0 to the public releases 0.1.0 and 0.1.1:
alter extension pg_liquid update to '0.1.1';
The repository regression sql/liquid_upgrade.sql validates both:
- empty install/upgrade behavior
- data-bearing
1.0.0 -> 0.1.1upgrades
Rollout Checklist
Recommended rollout sequence:
- Install the extension binaries on the target PostgreSQL host.
- Rehearse
create extensionoralter extension ... updateon a staging copy. - Apply the extension change in production.
- Lock down direct access to the underlying
liquidtables. - If using table-authoritative normalization, register normalizers and backfill.
- Run smoke queries through
liquid.query(...). - Verify benchmark and regression expectations on representative data.
Privilege Model
CLS is enforced by the Liquid execution functions, not by direct reads from the storage tables. Production deployment should grant the narrowest useful function surface while restricting direct reads from the graph tables.
Recommended pattern:
revoke all on schema liquid from public;
revoke all on all tables in schema liquid from public;
revoke all on all functions in schema liquid from public;
grant usage on schema liquid to app_user;
grant execute on function liquid.read_as(text, text) to app_user;
liquid.read_as(text, text) is the supported least-privilege read surface for
application roles. It is SECURITY DEFINER, binds principal context per call,
and rejects top-level assertions.
liquid.query(text) and liquid.query_as(text, text) remain SECURITY
INVOKER. They are still the right surfaces for trusted operators and
write-capable server code, but EXECUTE alone is not enough for an unprivileged
direct SQL role because they read the underlying liquid tables as the caller.
That means the privilege model is:
- app and AI reader roles:
USAGEon schema plusEXECUTEonliquid.read_as(text, text) - trusted write-capable server code:
liquid.query_as(text, text)orliquid.query(text)with the needed internal privileges - operator workflows: normalizer management and maintenance sessions stay privileged
pg_liquid.policy_principal is still caller-controlled session state. A direct
SQL client that can SET or RESET it can impersonate another principal or
disable CLS filtering for liquid.query(...). Prefer liquid.read_as(...) or
liquid.query_as(...) instead of exposing raw GUC management to application
code.
If application code should manage row normalizers, it also needs execute access to:
liquid.create_row_normalizer(...)liquid.drop_row_normalizer(...)liquid.rebuild_row_normalizer(...)
Those normalizer management functions also depend on internal liquid objects.
Treat them as privileged operator APIs today, not as a ready-made least-privilege
application surface.
Session Setup
Preferred application path:
select *
from liquid.read_as('user:alice', $$
Edge(subject_literal, "name", object_literal)?
$$) as t(subject_literal text, object_literal text);
liquid.read_as(...) is the preferred application read path. It binds the
request principal for the duration of the call, evaluates CLS as that principal
or any inherited liquid/acts_for ancestor, and rejects top-level assertions.
liquid.query_as(...) remains available for trusted write-capable wrappers that
need to seed or mutate Liquid state before querying.
pg_liquid.policy_principal remains available for privileged maintenance or
manual operator sessions:
set pg_liquid.policy_principal = 'user:alice';
reset pg_liquid.policy_principal;
Table Normalizers
Row normalizers are the supported way to project an authoritative relational table into Liquid compounds.
Preconditions:
- the source relation must be a base table
- the source table must have a primary key
- the compound type must already be defined in the Liquid graph schema
- the
role_columnsJSON object must map every compound role exactly once - if any mapped source column is
NULL, that row projects no compound
To project a table into a compound type:
select liquid.create_row_normalizer(
'public.film_performances'::regclass,
'film_perf',
'FilmPerf',
'{"actor":"actor_name","film":"film_title","role":"role_name"}'::jsonb
);
This installs an AFTER INSERT OR UPDATE OR DELETE FOR EACH ROW trigger on the
source table and, by default, backfills existing rows.
Recommended rollout sequence for a new normalizer:
- Define the target compound schema through
liquid.query(...). - Create the row normalizer with
backfill => truein staging first. - Verify projected compounds through
liquid.query(...). - Promote the same registration in production.
Runtime behavior:
- inserts project a compound and its role edges
- updates diff old and new projections rather than blindly deleting/reinserting
- deletes remove bindings and only tombstone graph edges when no other row still supports the same triple
- duplicate relational rows may project the same compound safely
Operational helpers:
select liquid.rebuild_row_normalizer(
'public.film_performances'::regclass,
'film_perf'
);
select liquid.drop_row_normalizer(
'public.film_performances'::regclass,
'film_perf',
true
);
Use rebuild_row_normalizer(...) after schema drift, manual graph repair, or
if you need to regenerate bindings from the source table. Use
drop_row_normalizer(..., purge => true) to remove the trigger and deproject
all graph facts maintained by that normalizer.
Smoke Checks
Minimal post-deploy checks:
select * from liquid.query($$
Edge("a", "knows", "b").
Edge(x, "knows", y)?
$$) as t(x text, y text);
For CLS validation:
select * from liquid.query($$
DefPred("owner", "1", "liquid/node", "0", "liquid/string").
DefPred("name", "1", "liquid/node", "0", "liquid/string").
Edge("name", "liquid/readable_if_subject_has", "owner").
Edge("person:alice", "owner", "user:alice").
Edge("person:alice", "name", "Alice").
Edge(subject_literal, "name", object_literal)?
$$) as t(subject_literal text, object_literal text);
Run that once with:
set pg_liquid.policy_principal = 'user:alice'
Expected:
- one visible
namerow forperson:alice
Then run with:
reset pg_liquid.policy_principal
Expected:
- all live matching facts are visible
Rollback
There is no reverse migration shipped from 0.1.0 back to the legacy internal
1.2.0, 1.1.0, or 1.0.0 versions.
Operationally, rollback means one of:
- restore the database from backup/snapshot
- restore the older extension binaries and database state together
- treat rollout as forward-only and fix forward if the extension has already mutated data
Do not assume alter extension ... update can be reversed.