Cross-Cutting Modules and Transformers
BifrostQL’s module system handles cross-cutting concerns through metadata configuration. No custom code required for common patterns like tenant isolation, claim-based filters, soft-delete, and audit columns.
Need to plug in your own logic — a custom filter, a mutation veto, an async validator, a computed column? See Extending BifrostQL (Hooks & Providers) for the programming surface.
Architecture
Section titled “Architecture”The module system has these extension points:
| Extension | Interface | Purpose | Behavior |
|---|---|---|---|
| Filter Transformer | IFilterTransformer |
Inject WHERE clauses | Applied to every SELECT. Throws to abort queries. |
| Mutation Transformer | IMutationTransformer |
Transform mutation operations | Can change operation type (e.g., DELETE → UPDATE). Runs TransformAsync. |
| Before-Commit Hook | IBeforeCommitMutationHook |
Veto a mutation inside the transaction | Returns errors (or throws) to roll back before commit. |
| Query Observer | IQueryObserver |
Side effects at lifecycle phases | Exceptions are logged but don’t abort queries. |
| Server Validator | IServerValidationProvider |
Async per-mutation validation | Returns error messages; any error aborts the mutation. |
| Computed Column | IComputedColumnProvider |
Provider-backed virtual fields | Computed after the row is read. |
The built-in transformers (tenant filter, auto-filter, soft-delete, audit columns, policy, state machine, enum value, server validation) auto-register from metadata — declaring the metadata key is enough; you do not wire them up in code. See Extending BifrostQL to register your own.
Tenant isolation
Section titled “Tenant isolation”The tenant filter transformer injects a WHERE clause on every query to a configured table, using the authenticated user’s tenant ID.
Configuration
Section titled “Configuration”{ "BifrostQL": { "Metadata": [ "dbo.orders { tenant-filter: tenant_id; }", "dbo.products { tenant-filter: tenant_id; }", "dbo.invoices { tenant-filter: tenant_id; }" ] }}Or apply to all tables that have a tenant_id column:
"dbo.*|has(tenant_id) { tenant-filter: tenant_id; }"How it works
Section titled “How it works”- BifrostQL reads
tenant_idfrom the authenticated user’s JWT claims viaBifrostContext - Every query to a tenant-filtered table gets
WHERE tenant_id = @tenantIdappended - If the tenant ID is missing from the user context, the query is aborted with an error
There is no opt-out per query. The filter is applied at the SQL level before execution. Queries physically cannot return data from another tenant.
Writes and foreign keys
Section titled “Writes and foreign keys”Writes are scoped too. An insert pins the tenant column to the caller’s tenant, and an update or delete only reaches the caller’s rows.
A write also cannot point a foreign key at another tenant’s row. When an insert or update — single-row, upsert, batch, updateWhere, or a nested sync/save tree — sets a database foreign key whose parent table declares tenant-filter, BifrostQL checks inside the write’s transaction that the parent row exists and belongs to the caller’s tenant. It locks that parent row until commit, so its tenant cannot change underneath the write. A parent inserted earlier in the same tree passes.
- A
NULLforeign key references nothing and is not checked. - A parent table without
tenant-filter(shared lookup data such as countries) is not checked. - A composite foreign key is checked on all of its columns together. An update that sets only some columns of a composite foreign key is refused.
- Name-based and polymorphic relationships are conventions, not constraints, and are not checked.
- There is no admin bypass, matching the rest of tenant isolation. A missing tenant claim fails closed.
A refused write fails with ACCESS_DENIED and one constant message, whether the parent does not exist or belongs to another tenant, so the answer cannot be used to discover other tenants’ keys:
A referenced row is not available.Tables with such a foreign key always use the per-row batch path; the bulk set-based fast path cannot run the check.
Configuring the claim key
Section titled “Configuring the claim key”By default, BifrostQL looks for tenant_id in the user context. Change the claim key with model-level metadata:
":root { tenant-context-key: org_id; }"Automatic claim filters
Section titled “Automatic claim filters”Use auto-filter when the filter is not strictly a tenant ID, or when one table needs several claim-to-column mappings.
{ "BifrostQL": { "Metadata": [ "dbo.orders { auto-filter: organization_id:org_id,region_id:region; }", ":root { auto-filter-bypass-role: admin; }" ] }}Each mapping is column:claim. The transformer reads the claim from the authenticated user context and injects an equality filter for that column. If the claim value is an array, the generated SQL uses an IN filter. The bypass role is read from UserContext["roles"].
Soft delete
Section titled “Soft delete”Soft delete converts DELETE mutations into UPDATE mutations that set a timestamp, and adds a filter to all SELECT queries to exclude soft-deleted rows.
Configuration
Section titled “Configuration”{ "BifrostQL": { "Metadata": [ "dbo.orders { soft-delete: deleted_at; soft-delete-by: deleted_by_user_id; delete-type: soft; }" ] }}What changes
Section titled “What changes”- Queries: A
WHERE deleted_at IS NULLfilter is added to every query against the table - DELETE mutations: Transformed into
UPDATE ... SET deleted_at = NOW(), deleted_by_user_id = @userId - Schema: Soft-deleted tables expose extra query and mutation arguments for administrative access
| Argument | Side | Effect |
|---|---|---|
_includeDeleted: Boolean |
query | Include soft-deleted rows alongside live rows |
_onlyDeleted: Boolean |
query | Return only soft-deleted rows (takes precedence) — power a “recycle bin” view |
_hardDelete: Boolean |
mutation | Bypass the soft-delete rewrite and issue a real DELETE — opt-in only, see below |
# Administrative read — show the recycle bin{ orders(_onlyDeleted: true) { data { orderId deletedAt } }}Hard delete is off by default
Section titled “Hard delete is off by default”_hardDelete physically removes rows, so it is not part of the schema of a plain
soft-delete table. A table exposes the argument only when it opts in by naming the role
allowed to use it:
"dbo.orders { soft-delete: deleted_at; soft-delete-hard-role: purge_admin; }"On an opted-in table the caller must also hold the named role (UserContext["roles"]).
Without the opt-in the argument is absent from the SDL, so the hard-delete branch is
unreachable from GraphQL; a programmatic mutation intent that still carries the key is
denied.
The hard-delete branch deliberately adds no deleted_at IS NULL guard: purging
already-soft-deleted rows is its purpose (a soft delete can never reach them). The role
opt-in — not the row’s delete state — is the guard.
# Permanently remove a row (requires soft-delete-hard-role opt-in + the role)mutation { orders(delete: { orderId: 42 }, _hardDelete: true) { orderId }}The standard delete mutation shape is unchanged: orders(delete: { orderId: 42 }) soft-deletes.
Priority ordering
Section titled “Priority ordering”Filter transformers run in priority order (lower number = applied first). The built-in priorities:
- 0-99: Security and tenant isolation
- 100-199: Data filtering (soft-delete)
- 200+: Application-level filters
This means tenant filters are always applied inside (closer to the query), and soft-delete filters wrap around them.
Audit columns
Section titled “Audit columns”The audit module auto-populates columns from the authenticated user context during mutations.
Configuration
Section titled “Configuration”{ "BifrostQL": { "Metadata": [ "dbo.*.createdOn { populate: created-on; }", "dbo.*.updatedOn { populate: updated-on; }", "dbo.*.createdBy { populate: created-by; }", "dbo.*.updatedBy { populate: updated-by; }" ] }}Populate values
Section titled “Populate values”| Value | Populated with |
|---|---|
created-on |
Current timestamp (on insert only) |
updated-on |
Current timestamp (on insert and update) |
created-by |
User audit key from context (on insert only) |
updated-by |
User audit key from context (on insert and update) |
deleted-on |
Current timestamp (on soft-delete) |
deleted-by |
User audit key from context (on soft-delete) |
Populated columns are optional in the GraphQL input types and server-owned: a write overwrites any client-supplied value for the columns it stamps (created-* on insert, updated-* on insert and update, deleted-* on delete), and an update drops a client-supplied created-* value. There is no update: none key; the model loader rejects it.
Query observers
Section titled “Query observers”Observers receive notifications at four lifecycle phases:
- Parsed – after the GraphQL query is parsed into the internal query tree
- Transformed – after filter and mutation transformers have run
- BeforeExecute – immediately before SQL execution
- AfterExecute – after SQL execution completes
Observers are for side effects: logging, metrics, auditing. If an observer throws, the exception is logged and the query continues. Observers do not modify queries.
Visibility control
Section titled “Visibility control”Hide tables and columns from the GraphQL schema entirely:
"dbo.sys* { visibility: hidden; }""dbo.*.__* { visibility: hidden; }""dbo.*.password_hash { visibility: hidden; }""dbo.*.internal_notes { visibility: hidden; }"Hidden items are excluded from the generated schema. They don’t appear in queries, mutations, or introspection results.