Persistence and authority
Typed persistence ports, adapter conformance, PersistentModel and recorded authority.
Persistence ports generate typed services and adapter conformance suites. Models and capabilities remain recorded facts in the IR; they do not generate SQL or an authorization runtime.
| Area | Status |
|---|---|
PersistentModel, views, focus | Recorded as Model and Focus nodes (implemented, packages/compiler/src/extensions/core.ts). |
Capability, Authorize | Recorded as Capability and Focus nodes with AuthorizedBy and Focuses edges (implemented). |
| Persistence ports | Optional @effx/persistence extension: leaf services and conformance suites; application-owned Effect SQL and Drizzle adapter Layers (spec 0022). |
SQL projection from table | Spec 0008 is superseded in scope by spec 0022. No SQL, migrations, ORM schema or CRUD repository is generated. |
| Cedar authorization, leases | Deferred past the MVP kernel (ADR 0007). Optics are a later runtime mechanism, never the identity. |
Persistence ports
import { Operation } from "@effx/runtime";
import { Persist } from "@effx/persistence/syntax";
export const FindUser = Operation.query({
name: "Users.find", input: FindUserInput, success: User,
})
.errors(UserNotFound)
.with(Persist.Port({ port: "Users" }))
.declare();Register the compiler half in your project config:
import { defineConfig } from "@effx/cli/config";
import { persistenceExtension } from "@effx/persistence/compiler";
export default defineConfig({ extensions: [persistenceExtension] });A port operation has no local handler and no HTTP, RPC or CLI exposure. The
operation name must begin with <port>.; the remainder names the service method.
The compiler emits <port>-port.ts and <port>-conformance.ts, using the original
Schema references. Method requirements are never; adapter requirements belong
to the Layer selected at the composition root. Core packages do not import the
optional persistence package (spec 0022 §§2–3, 7; ADR 0004/0008).
Use builder .declare() for declaration-only ports. Decorators contribute the
same annotation data, but a decorated local-bodied method remains a handler and
diagnoses EFFX3401. Decorator declaration-only ports are a non-goal: TypeScript
cannot decorate abstract or declare members (spec 0022 §2 and the dated
implementation amendment).
The generated suite checks closed success/error channels, query purity and repeatability, command rollback, and shared transaction rollback/commit. Supply domain scenarios for seed data, expected successes, which inputs raise declared errors, and the commit observer. The harness owns isolation and the common transaction owner; the compiler cannot derive domain expectations (spec 0022 §4).
One physical store belongs to each test. Between arbitrary samples, the harness resets it and the generated assertion requires its snapshot to equal the empty baseline captured before first seeding. Only then are domain fixtures restored; an incomplete reset fails conformance rather than leaking state into later samples (spec 0022 §4 implementation amendment).
G1 checks all failure reasons against the declared schemas; several typed failures are valid, but defects, interruptions and undeclared failures reject conformance. G2 compares Type-side values and failure multisets, preserving duplicate counts without depending on concurrent completion order. Domain error scenarios and the rollback sentinel retain one expected failure (spec 0022 §4 implementation amendment).
examples/persistence provides Effect SQL and patched drizzle-effect adapters on
PGlite, the real PostgreSQL engine compiled to WASM. They share the ambient Effect
SQL transaction. PGlite is single-connection: scenarios marked
requiresConcurrentConnections are skipped, and lock contention/isolation are
not certified. Interruption tests record outcomes without promising rollback in
advance. effx generates no SQL, migrations, repository, ORM schema or transport
transaction wrapper (spec 0022 §§5–6).
PersistentModel
// decorator: the decorated class is the model schema
@PersistentModel({ table: "users", views: { Public: UserPublic, Self: UserSelf }, focus: { email: ["email"] } })
export class User extends Schema.Class<User>("User")({ /* ... */ }) {}
// builder: the schema is the first argument
export const UserModel = Model.persistent(User, { table: "users", focus: { email: ["email"] } });PersistentModelOptions
| Field | Type | Optional | Meaning |
|---|---|---|---|
table | string | no | Table name. Recorded on the Model node and as the PersistsAs edge qualifier. |
views | Readonly<Record<string, Schema.Top>> | yes | Named view schemas of the model (for example Public, Self). Each becomes a ViewOf edge qualified by its name. |
focus | Readonly<Record<string, Focus | ReadonlyArray<string>>> | yes | Named focus paths inside the model. The IR stores each as a string path. |
Source: packages/runtime/src/Annotation.ts (PersistentModelOptions); IR in
packages/compiler/src/extensions/core.ts (PersistentModelArgs, persistentModel).
Both forms record PersistentModel [{ ...options, schema }], where schema is the decorated class or the
Model.persistent first argument. Model.persistent<S extends Schema.Top>(schema: S, options) evaluates to
ModelValue<S>: { _tag: "Model", annotations, schema }.
| Decorator | Builder |
|---|---|
@PersistentModel({ table, views?, focus? }) on a class C | Model.persistent(C, { table, views?, focus? }) |
Source: packages/runtime/src/decorators.ts, packages/runtime/src/builder.ts.
IR produced (spec 0001, core.ts):
| Node or edge | Content |
|---|---|
Model node model:<schema export> | name, schema, table, views: [{ name, schema }] |
Focus node focus:<Model>.<name> | root (the model), path |
PersistsAs edge | model to its schema, qualifier is the table |
ViewOf edge | view schema to the model, qualifier is the view name |
Model identity is the schema's export, so @PersistentModel class User and Model.persistent(User, ...)
name the same model model:User (spec 0002).
Focus
A focus is a serializable identity: a model root plus a key path (ADR 0007).
| Field | Type | Optional |
|---|---|---|
_tag | "Focus" | no |
root | Schema.Top | no |
path | ReadonlyArray<string> | no |
Constructor: Focus.key(root: Schema.Top, ...path: ReadonlyArray<string>) returns that value.
Focus.key(User, "email"); // { _tag: "Focus", root: User, path: ["email"] }Source: packages/runtime/src/Annotation.ts (Focus), packages/runtime/src/authority.ts.
The frontend lowers Focus.key(Root, "a", "b") or a string array to the path ["a", "b"] (spec 0002
§"Argument lowering"). In the IR the focus root is the model, not a live schema.
Capability
A capability is a kind of authority over a model, optionally narrowed to a focus.
Capability value
| Field | Type | Optional | Meaning |
|---|---|---|---|
_tag | "Capability" | no | |
name | string | no | Capability name; becomes capability:<name>. |
resource | Schema.Top | no | The model schema the authority applies to. |
focus | Focus | yes | Narrows the authority to a path in the model. |
Constructor: Capability.make(name: string, options: { readonly resource: Schema.Top; readonly focus?: FocusValue }).
The runtime exports the value type as CapabilityValue. focus is omitted from the result when undefined.
Source: packages/runtime/src/Annotation.ts (Capability), packages/runtime/src/authority.ts.
The same Capability export also holds the access-expression constructors one, any, all and none.
Those belong to Http.Access; see Problems and access.
@Authorize
@Authorize(capability) and .authorize(capability) link an operation to a capability. The frontend lowers a
Capability.make call to { name, resource: <resource export>, focus?: string[] }. The interpreter records:
| Recorded | When |
|---|---|
Capability node capability:<name>, resource = model:<export> | always |
AuthorizedBy edge, operation to capability | always |
Focus node focus:<resource>.<path joined by ".">, Focuses edge capability to focus | when focus is present |
Source: packages/compiler/src/extensions/core.ts (AuthorizeArgs, authorize).
ADR 0007: Cedar deferred
ADR 0007 is accepted and deferred past the MVP kernel. The kernel only records capability and focus facts.
The following do not exist yet (docs/decisions/0007-cedar-authorizes-effx-issues-leases.md):
- the Cedar runtime decision (principal, action, resource, context; default deny;
forbidoverridespermit); - the request-scoped
LeaseandLease.modifyflow; - Cedar schema generation from the IR and
EFFX41xxpolicy diagnostics; - optics as the runtime mechanism for focus.
For the mono-web migration, the application reuses its own AccessSpec interpreter with fresh authority resolved inside each committing transaction. See Problems and access.
Example
import { PersistentModel } from "@effx/runtime";
import { Schema } from "effect";
import { Email, UserId, UserPublic, UserSelf } from "./schemas.ts";
@PersistentModel({
table: "users",
views: { Public: UserPublic, Self: UserSelf },
focus: { email: ["email"] },
})
export class User extends Schema.Class<User>("User")({
id: UserId,
email: Email,
displayName: Schema.String,
}) {
static readonly Public = UserPublic;
static readonly Self = UserSelf;
}
import { Capability, Focus } from "@effx/runtime";
import { User } from "./user.ts";
export const Read = Capability.make("User.Read", { resource: User });
export const ChangeEmail = Capability.make("User.ChangeEmail", {
resource: User,
focus: Focus.key(User, "email"),
});
Builder twin of the model (compiler fixture):
import { Model } from "@effx/runtime";
import { User } from "./user.ts";
export const UserModel = Model.persistent(User, { table: "users", focus: { email: ["email"] } });