05. Database
Scrivener is the model layer. It never learns which database it is talking to.
The query IR
The requirement is that nobody thinks about PostgREST, or about SQL dialects, or about which driver is configured. That means the model layer cannot be built on a driver's query builder, because every leaked quirk becomes application-visible.
The model and query builder produce a serializable QueryIR. The driver compiles it.
Revised after M0: the spike proved result parity on every expressible operation and failed the assumption on underdefinition. Everything a compiler would otherwise have to guess is now explicit (D25).
export type CompareOp = '=' | '!=' | '<' | '<=' | '>' | '>=' | 'like' | 'ilike'
export interface OrderTerm {
column: string
direction: 'asc' | 'desc'
nulls?: 'first' | 'last'
}
export interface RelationLoad {
/** Relation name as declared on the model. Keys the loaded rows in the result. */
relation: string
kind: 'belongsTo' | 'hasOne' | 'hasMany'
/** Resolved by the model layer so drivers never introspect models. */
table: string
/** Column on the parent row. */
localKey: string
/** Column on the related table. */
foreignKey: string
select: string[] | '*'
where: Predicate | null
order: OrderTerm[]
limit?: number
/** Nested loads. Total depth is checked against the driver's maxRelationDepth. */
relations: RelationLoad[]
}
export interface QueryIR {
table: string
mode: 'select' | 'count' | 'insert' | 'update' | 'delete' | 'upsert'
select: string[] | '*'
/** Entries compose with AND. */
where: Predicate[]
relations: RelationLoad[]
order: OrderTerm[]
limit?: number
offset?: number
values?: Record<string, unknown> | Record<string, unknown>[]
returning?: string[] | '*'
/**
* Required when mode is 'upsert'. `update` names the columns overwritten on conflict;
* `'*'` means every supplied value column not named in `conflict.columns`.
*/
conflict?: { columns: [string, ...string[]]; update: [string, ...string[]] | '*' }
/** Injected by the ward compiler, ANDed with `where`. Never author-supplied. */
ward?: Predicate
}
export type Predicate =
| { kind: 'const'; value: boolean }
| { kind: 'compare'; column: string; op: CompareOp; value: unknown }
| { kind: 'null'; column: string; negated: boolean }
| { kind: 'in'; column: string; values: unknown[]; negated: boolean }
| { kind: 'and'; predicates: Predicate[] }
| { kind: 'or'; predicates: Predicate[] }
| { kind: 'not'; predicate: Predicate }
Defined semantics
These rules are part of the contract, not driver behavior. Every compiler normalizes before emitting, so two drivers can never disagree about what an IR means:
whereentries and the injectedwardcompose with AND.and: []isconst true.or: []isconst false. They are the identity elements, so
machine-generated IR needs no special cases.
in: []matches nothing; negated, it matches everything.notoverconstfolds to the oppositeconst.- A
const falseward or where-set short-circuits: the driver returns an empty result (or affects
zero rows) without a round trip. A const true compiles to nothing.
- Comparisons follow SQL three-valued logic. Client-side ward scoping evaluates the same way, which
M0 verified against live RLS on adversarial null fixtures.
upsertwithoutconflictis invalid IR and is rejected before it reaches a driver.countreturns the number of rows matchingwhereANDward, ignoringselect,relations,
order, limit, and offset, which must be absent or empty. It exists so paginate() has a portable total; drivers surface it as QueryResult.count.
- Relation
limitapplies per parent row, not to the combined related set.
@avelon/supabase compiles that to PostgREST parameters. @avelon/postgres compiles it to parameterized SQL. A MySQL driver would compile it to MySQL. The model layer never learns which.
The IR is serializable on purpose: it can be logged, snapshot-tested, and diffed, which is how you prove two drivers agree on the same query without a live database.
Capabilities that matter
capabilities: {
transactions: false, // supabase: use rpc()
rowSecurity: true, // wards compile to RLS
maxRelationDepth: 2, // PostgREST embed limit
fullTextSearch: true,
upsert: true,
returning: true,
windowFunctions: false,
jsonOperators: true,
}
maxRelationDepth is checked by Bailiff against every .with() chain in the codebase, so exceeding the configured driver's limit is a lint error at authoring time rather than an empty array at runtime.
Models
export class Post extends Model {
static table = 'posts'
static fillable = ['title', 'body']
static casts = { published_at: 'datetime', meta: 'json' } as const
static timestamps = true
static softDeletes = true
declare id: string
declare title: string
declare body: string
declare user_id: string
declare published_at: Date | null
author() { return this.belongsTo(User, 'user_id') }
comments() { return this.hasMany(Comment, 'post_id') }
static published() { return this.query().whereNotNull('published_at') }
}
declare is required rather than stylistic: an emitted class field would overwrite hydrated values during construction.
await Post.published().with('author').latest().paginate(15)
await Post.findOrFail(id)
await Post.query().where('user_id', user.id).where('views', '>', 100).get()
await post.update({ title })
await post.delete() // soft delete when enabled
await Post.withTrashed().find(id)
Eager loading issues a second query with a whereIn, never an N+1 loop. On drivers that support embeds it may collapse into one round trip, and that is a driver decision the model layer does not see.
Model lifecycle
Hooks dispatch on the same event bus as application events, which is what makes Observers ordinary listeners:
creating created updating updated saving saved deleting deleted restored
A creating listener returning false aborts the write. See 07-events.md.
Types
reeve schema:pull generates database/types.ts from the live schema. Model attribute declarations are checked against it by Bailiff, so a column rename is a type error rather than a runtime surprise in production.
This runs in CI against the migration-applied schema, so a model that drifts from its table fails the build.
Transactions
Driver capability, surfaced by narrowing.
// @avelon/postgres configured
await DB.transaction(async (tx) => {
const order = await Order.on(tx).create({ ... })
await Payment.on(tx).create({ order_id: order.id, ... })
})
// @avelon/supabase configured: DB.transaction does not exist.
// Multi-statement atomicity goes through a Postgres function:
await DB.rpc('create_order_with_payment', { ... })
Bailiff errors on hand-rolled "transaction" helpers that wrap sequential writes in a try/catch, since that pattern reads as atomic and is not.
Migrations
Driver-owned. The contract is plan, apply, rollback, status, and reeve migrate is the same command regardless of driver.
@avelon/supabase emits timestamped SQL into a single Supabase-compatible history and shells out to the Supabase CLI, so nothing about the migration workflow is Avelon-specific and an existing project keeps working.
reeve make:migration create_posts_table
reeve migrate
reeve migrate:rollback
reeve migrate:fresh --seed
reeve migrate:status
Ward compilation emits into the same history, so row security travels with the schema rather than living in a dashboard nobody has read since setup. See 06-authorization.md.
A schema builder DSL is deferred, not rejected. See D13.