Custom Database
Implement effect-auth store contracts for an unsupported backend or existing schema.
Use a custom adapter when the database is unsupported or auth must fit an existing schema. Do not copy the maintained SQLite schema unless it fits the backend. Implement the public Effect services required by the composed program and preserve their behavior.
There is no generic Storage service or makeStorage factory. Context services use .of(...); provide each value through a Layer.
Base contracts
The base services are exported by @effect-auth/core/Storage:
| Contract | Responsibility | Critical semantics |
|---|---|---|
UserStore | User lookup and insert | Preserve IDs, disabled state, metadata, timestamps |
IdentityStore | Login identity lookup, verification, primary selection, replacement, revocation | Normalize before lookup; return IdentityConflictError for known conflicts |
CredentialStore | Password credential lookup, insert, CAS update | Store hashes unchanged; updatePassword checks the expected hash |
RegistrationStore | Create user, identity, and optional password credential | One atomic create; dedicated conflict/failure errors |
SessionStore | Session lifecycle, bearer lookup, claims, evidence, rotation, revocation | Narrow bearer read; refresh and assurance rotation are exact active CAS |
VerificationStore | One-time challenges and expiry cleanup | One winner; expiresAt <= consumedAt is expired |
Use exact exported service, row, and input types. Branded IDs and UnixMillis constructors are nominal casts, not untrusted-input validators; decode persisted values with the exported schemas. Preserve optional fields, JSON values, and caller-supplied mutation timestamps.
SessionStore.findBearerById(sessionId) is a pre-authentication boundary. Project only the session ID and secretHash, decode them as SessionBearerSnapshot, and do not select or decode authentication events, claims, metadata, assurance summaries, or an application full-row codec. findById remains the authenticated full-row read.
Features add stores in their owning public modules, for example PasskeyCredentialStore, RefreshTokenStore, and stores in Totp, RecoveryCode, and OAuth. API keys, trusted devices, domain verification, audit data, and timelines likewise own focused contracts. Let the Effect environment reveal missing services; never provide placeholders that always return Option.none().
Atomic transitions
Prefer one conditional mutation with a returned row. Otherwise use a transaction whose isolation and locking prove the same outcome.
UPDATE auth_verification
SET consumed_at = :consumedAt
WHERE id = :id
AND consumed_at IS NULL
AND expires_at > :consumedAt
AND (:typeWasOmitted OR type = :type)
RETURNING *;Exactly one concurrent consumer may receive Option.some. A select followed by an unconditional update is incorrect. Apply the same rule to consume, claim, lease, refresh-token rotation, passkey counters, and compare-and-set operations. SessionStore.refresh must update only when expectedSecretHash and exact expectedAuthenticationEvents still match, revokedAt is null, and expiresAt > now; return Option.none() on a CAS miss. Keep RegistrationStore.create and other declared multi-row transitions in one transaction.
| Outcome | Return |
|---|---|
| Missing or conditionally ineligible row | Contract-specified Option.none() |
| Known identity uniqueness conflict | IdentityConflictError |
| Known registration conflict or transaction failure | RegistrationConflictError or RegistrationFailureError |
| Driver, timeout, decode, or unexpected backend failure | StorageError |
Use StorageError.fromUnknown(entity, operation, cause) at the backend boundary. Do not put credentials, hashes, tokens, or complete auth rows in messages.
Implementation skeleton
import {
StorageError,
UserStore,
type UserRow,
} from "@effect-auth/core/Storage";
import { Effect, Layer, Option } from "effect";
interface Database {
readonly findUserById: (id: string) => Promise<unknown | undefined>;
readonly insertUser: (row: unknown) => Promise<void>;
}
declare const decodeUser: (
value: unknown
) => Effect.Effect<UserRow, StorageError>;
export const UserStoreLive = (db: Database) =>
Layer.succeed(
UserStore,
UserStore.of({
findById: (id) =>
Effect.tryPromise({
try: () => db.findUserById(String(id)),
catch: (cause) => StorageError.fromUnknown("user", "find", cause),
}).pipe(
Effect.flatMap((value) =>
value === undefined
? Effect.succeed(Option.none<UserRow>())
: decodeUser(value).pipe(Effect.map(Option.some))
)
),
insert: (row) =>
Effect.tryPromise({
try: () => db.insertUser(row),
catch: (cause) => StorageError.fromUnknown("user", "insert", cause),
}),
})
);Merge only the service layers required by your auth composition. Password registration additionally needs IdentityStore, CredentialStore, and atomic RegistrationStore.create({ user, identity, passwordCredential }); do not emulate registration with independent inserts.
Test checklist
- Round-trip every field, optional value, boolean, JSON value, branded ID, and millisecond timestamp.
- Separate missing-row
Optionbehavior from conflict, driver, timeout, and decode failures. - Verify uniqueness, revoked/active behavior, exact expiry boundaries, limits, ordering, and partial-update preservation.
- Race every consume, claim, lease, rotation, and CAS; assert permitted winners and unchanged losers.
- Force rollback during every multi-row transition and test concurrent session/claims updates for lost writes.
- Cover every enabled feature store, including replay and terminal-state cases.
Run these tests against the real engine and production transaction mode. Internal repository runners in packages/core/test/StorageContract.ts are executable specifications, not package exports; do not import test or dist subpaths.