Custom database adapter
Implement Ownfold's vault and rotation storage contracts with explicit ownership, revisions, transactions, validation, and compliance tests.
Build a custom adapter when the official SQLite, PostgreSQL, Drizzle, and Prisma packages do not support your database or runtime. The adapter stores coordination metadata; application records remain in your own repository.
Install contracts and tests
pnpm add @ownfold/core@beta better-result
pnpm add -D @ownfold/testing@beta vitest
Implement both VaultAdapter and VaultRotationAdapter for full lifecycle support.
import type { VaultAdapter, VaultRotationAdapter } from "@ownfold/core"
export class ApplicationVaultAdapter implements VaultAdapter, VaultRotationAdapter {
// Implement the methods documented below.
}
Do not use an unsafe type assertion to claim compatibility. Let TypeScript verify every method, input, and result.
Vault methods
| Method | Required behavior |
|---|---|
getVault |
Return the user’s vault or null; validate stored data before returning it. |
createVault |
Be idempotent for the same user/vault input and reject conflicting ownership. |
updateRecoveryStatus |
Match expectedRevision, update status, and increment the revision atomically. |
replaceRecoveryKit |
Match current kit ID and revision, preserve verified status, and increment revision. |
listDevices |
Scope every row by user and vault; never return another owner’s device. |
registerDevice |
Be idempotent for the same device input and reject conflicting reuse. |
touchDevice |
Move lastActiveAt forward only and do not increment the security revision. |
revokeDevice |
Match the expected revision, set revocation time, and reject stale writes. |
createPairing |
Persist a pending offer scoped to the authenticated owner. |
getPairing |
Scope lookup by both user ID and request ID. |
approvePairing |
Approve the request and register its device in one atomic transaction. |
cancelPairing |
Match the expected revision and transition pending to cancelled atomically. |
Every method returns Result<T, StorageAdapterError | ConflictError> according to the public
contract. Database exceptions, SQL strings, connection details, and raw driver errors must not
cross the adapter boundary.
Rotation methods
| Method | Required behavior |
|---|---|
getRotation |
Return the current rotation for the user and vault, or null. |
beginRotation |
Match the vault revision and create one active from/to-version transition. |
updateRotationProgress |
Match the rotation revision and persist monotonic progress/checkpoint state. |
completeRotation |
Atomically update every active device envelope, Recovery Kit ID, vault key version, vault revision, and rotation state. |
completeRotation is the most important transaction. If any device revision is stale, the entire
operation must roll back. Partial device or vault updates can make data permanently inaccessible.
Ownership and keys
Use composite uniqueness and lookup predicates that include the application user ID where the contract provides it. Request IDs, vault IDs, and device IDs are not authorization credentials.
Recommended logical keys:
| Record | Key or uniqueness rule |
|---|---|
| Vault | One per userId; vaultId globally unique or additionally owner-scoped. |
| Device | Unique deviceId, with every operation scoped to userId + vaultId. |
| Pairing | Unique requestId, with every read/write scoped to userId. |
| Rotation | At most one active rotation per userId + vaultId. |
Validate stored rows
Database contents are untrusted input. Parse JSON envelopes and reconstructed records with
@ownfold/core schemas before returning them. A malformed stored row must become a typed
StorageAdapterError, not a partially populated domain value.
Do not silently replace invalid fields with defaults. Preserve the row for investigation and make the failure observable to operators without exposing stored ciphertext in logs.
Map conflicts explicitly
Convert expected concurrency and uniqueness failures to ConflictError with a stable code. Convert
driver outages, malformed rows, and unexpected transaction failures to StorageAdapterError.
Examples of conflicts:
- a stale
expectedRevision; - reuse of a device ID with different key material;
- a pairing transition from a state other than pending;
- beginning a second incompatible rotation; and
- completing rotation after another operation changed a device revision.
Transaction boundaries
At minimum, these operations require atomic transactions:
- pairing approval plus device registration;
- recovery replacement revision check plus update;
- device revocation revision check plus update; and
- complete rotation across vault, rotation, and all device rows.
Use database-native compare-and-swap predicates rather than a read followed by an unconditional write. A transaction alone does not prevent lost updates if the expected revision is not part of the write predicate.
Run the compliance suite
import { checkVaultRotationAdapterCompliance } from "@ownfold/testing"
import { describe, expect, it } from "vitest"
import { createTestAdapter } from "./application-vault-adapter"
describe("ApplicationVaultAdapter", () => {
it("satisfies the Ownfold lifecycle and rotation contract", async () => {
const adapter = await createTestAdapter()
const result = await checkVaultRotationAdapterCompliance(adapter)
expect(result.status).toBe("ok")
})
})
Create a fresh isolated database for every run. The suite writes fixed compliance IDs and exercises idempotency, stale revisions, recovery replacement, device activity, revocation, pairing, rotation progress, atomic completion, and rollback after a stale device.
The suite is necessary but not sufficient. Add driver-specific tests for:
- schema migration from the previous released version;
- malformed JSON and missing columns;
- deadlocks, timeouts, and connection loss;
- real concurrent writers;
- transaction rollback after each statement; and
- the exact runtime and database version used in production.
Package design
Keep driver and ORM dependencies in the leaf adapter package. Do not add them to @ownfold/core or
@ownfold/server. Export a constructor or factory that accepts the application’s existing client
or query executor; do not create hidden global connections.
Document:
- supported driver and database versions;
- schema and migration ownership;
- connection and shutdown responsibility;
- transaction and isolation requirements;
- runtime limitations; and
- the tested compliance and real-database lanes.
Production checklist
- All stored values are parsed before entering Ownfold domain code.
- All ownership predicates include the authenticated user boundary.
- Stale revisions return conflicts and preserve existing state.
- Pairing approval is atomic with device registration.
- Rotation completion is atomic across every affected record.
- Errors are typed and scrubbed of driver details.
- The shared compliance suite passes against the real driver.
- Migration, concurrency, rollback, backup, and restoration tests pass.