> ## Documentation Index
> Fetch the complete documentation index at: https://docs.alfiz.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Alfiz Storage Drivers: Memory, Prisma, and MongoDB Reference

> memoryDriver() for tests, prismaDriver() and mongoDriver() for production. All satisfy the StorageDriver interface, so you can swap one for another without changing any application logic.

The storage seam is the single interface a database must satisfy to host an Alfiz Application. All application semantics (graph integrity, request workflows, org-root gating, the audit log) live above the seam in the Application layer and are identical regardless of which driver you use. Drivers store and retrieve; they never interpret. All IDs are assigned by the Application.

## The `StorageDriver` interface

```ts theme={null}
import type { StorageDriver } from "@alfiz/application";
```

A `StorageDriver` is a flat bundle of async methods organized into eight areas:

| Area         | Methods                                                                |
| ------------ | ---------------------------------------------------------------------- |
| **Grants**   | `insertGrant`, `deleteGrant`, `listGrants`, `countGrants`              |
| **Revokes**  | `insertRevoke`, `deleteRevoke`, `listRevokes`                          |
| **Roles**    | `upsertRole`, `getRole`, `listRoles`, `deleteRole`                     |
| **Groups**   | `upsertGroup`, `getGroup`, `listGroups`, `deleteGroup`                 |
| **Users**    | `getUser`, `upsertUser`, `deleteUser`, `listUsers`, `listUsersInGroup` |
| **Requests** | `insertRequest`, `updateRequest`, `getRequest`, `listRequests`         |
| **Catalog**  | `putCatalog`, `getCatalog`                                             |
| **Audit**    | `appendAudit`, `listAudit`                                             |

One additional method, `runExclusive(key, fn)`, serializes graph writes per key to prevent concurrent insertions from jointly forming a group or reporting cycle. Implement it with a SQL advisory lock in multi-node deployments (see [Multi-node note](#multi-node-deployments)).

### Optional methods

The following methods are optional. Custom drivers can omit them and keep compiling, and the Application either falls back to a slower path or fails loudly at construction if the feature depending on them is turned on.

| Method                                                  | Enables                                                                                                                 | Falls back to                                                    |
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `getRoles(ids)`                                         | Batched role fetch on the closure-supply path, with one call per miss instead of one per referenced role                | Parallel `getRole` per id                                        |
| `appendEvents`, `headSeq`, `eventsSince`, `pruneEvents` | The Application's `events: { persist: true }` option, a persisted invalidation log for cross-process cache revalidation | Construction throws when `events.persist` is on without them     |
| `putImports`, `getImports`                              | `publishImports` / `getPublishedImports`, and the drift report that names roles still referencing a tombstoned key      | `capabilities().imports` reports `false`; the methods are absent |
| `recordMetrics`, `readMetrics`, `pruneMetrics`          | The Application's `metrics: {}` option, with stored usage counters and the revocation safeguard                         | Construction throws when `metrics` is on without them            |

`memoryDriver` and `mongoDriver` implement the event-log methods out of the box (`mongoDriver` omits catalog history and metrics; see [its section](#mongodriver)). `prismaDriver` includes each group only when the corresponding delegate is present on the client you hand it, so if you merged only the ten core models and generated your client, `events: { persist: true }` and `metrics: {}` will still throw at construction. Add the [optional models](#optional-models), migrate, and regenerate first.

## `memoryDriver()`

The in-memory driver is the reference implementation of the storage seam. It is complete and correct. Every Application feature works with it, which makes it the natural choice for unit tests, local development, and ephemeral deployments.

```ts theme={null}
import { memoryDriver } from "@alfiz/application";
```

```ts theme={null}
import { createApplication, memoryDriver } from "@alfiz/application";

const app = createApplication({
  catalog,
  storage: memoryDriver(),
});
```

Each call to `memoryDriver()` returns an independent, empty store. Two calls produce two isolated stores, which helps when testing multi-tenant scenarios or provider federation in-process.

<Warning>
  The memory driver is not persistent. Everything is lost when the process exits. Use it for tests and local dev only; use `prismaDriver` (or a custom driver) for anything you need to survive a restart.
</Warning>

### Testing with the memory driver

```ts theme={null}
import { describe, it, expect, beforeEach } from "vitest";
import { createApplication, memoryDriver } from "@alfiz/application";
import { createAlfizClient } from "@alfiz/core";
import { catalog } from "./alfiz.js";

describe("document access", () => {
  let app: ReturnType<typeof createApplication>;
  let alfiz: ReturnType<typeof createAlfizClient<typeof catalog>>;

  beforeEach(() => {
    // Fresh isolated store per test, with no teardown needed.
    app = createApplication({ catalog, storage: memoryDriver() });
    alfiz = createAlfizClient({ catalog, provider: app });
  });

  it("denies before any grant", async () => {
    const result = await alfiz.can({ userId: "u1" }, "docs.files.read");
    expect(result).toBe(false);
  });

  it("allows after a grant", async () => {
    await app.createGrant({
      subject: "user:u1",
      pattern: "docs.files.read",
      provenance: { kind: "admin", actorUserId: "root" },
    });
    const result = await alfiz.can({ userId: "u1" }, "docs.files.read");
    expect(result).toBe(true);
  });
});
```

## `prismaDriver`

The Prisma driver is the production driver shipped in `@alfiz/prisma`. It maps the `StorageDriver` interface onto a generated Prisma client without taking `@prisma/client` as a hard dependency. It operates against a structural delegate interface instead.

### Installation

```bash theme={null}
npm install @alfiz/prisma
```

`@prisma/client` must be in your own dependencies and generated from a schema that includes the Alfiz models. `@alfiz/prisma` does not depend on it directly.

### Creating a Prisma driver

```ts theme={null}
import { prismaDriver } from "@alfiz/prisma";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

const storage = prismaDriver(prisma);
```

Pass the `PrismaClient` instance directly. Any Prisma client generated from a schema containing the Alfiz models satisfies the `AlfizPrismaDelegates` interface structurally, with no cast required.

### `PrismaDriverOptions`

`prismaDriver` accepts an optional second argument:

<ParamField body="lock" type="<T>(key: string, fn: () => Promise<T>) => Promise<T>" optional>
  A cross-node serialization function for graph writes. In a single-process deployment the default in-process promise-chain mutex is sufficient. In a **multi-node deployment** you must provide a database advisory lock here so concurrent graph writes on different nodes cannot jointly form a cycle. Example for Postgres:

  ```ts theme={null}
  const storage = prismaDriver(prisma, {
    lock: async (key, fn) => {
      return prisma.$transaction(async (tx) => {
        await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${key}))`;
        return fn();
      });
    },
  });
  ```

  The keys the driver hands to `lock` already carry the partition (`"docs:groups"` under `partition: "docs"`), so applications sharing one database never contend on each other's locks. Supplying `lock` also marks the driver cross-process capable (`crossProcess`), a prerequisite for accepting a [mesh write edge](/operations/shared-store#write-edges).
</ParamField>

<ParamField body="partition" type="string" optional>
  Pins the driver to one application's slice of [shared tables](/operations/shared-store). Rows are written and read with `app = partition`; the default `""` is the unpartitioned dataset. The recommended value is the application's primary catalog namespace. The delegate types make the discriminator required in every query shape, so a query inside the driver that forgot the partition fails to compile rather than scanning every tenant.
</ParamField>

### The delegate interface

`prismaDriver` requires only the subset of the generated Prisma client it actually calls, expressed as the `AlfizPrismaDelegates` interface. Ten delegate properties are required and map directly to the core Alfiz schema models; four more are **optional**, and a client generated without them still satisfies the interface:

```ts theme={null}
interface AlfizPrismaDelegates {
  alfizGrant: AlfizGrantDelegate;
  alfizRevoke: AlfizRevokeDelegate;
  alfizRole: AlfizRoleDelegate;
  alfizGroup: AlfizGroupDelegate;
  alfizGroupParent: AlfizGroupParentDelegate;
  alfizUser: AlfizUserDelegate;
  alfizMembership: AlfizMembershipDelegate;
  alfizRequest: AlfizRequestDelegate;
  alfizCatalog: AlfizCatalogDelegate;
  alfizAudit: AlfizAuditDelegate;

  // Optional: omit the model and the driver omits the matching methods.
  alfizImports?: AlfizImportsDelegate;
  alfizEpoch?: AlfizEpochDelegate;
  alfizEvent?: AlfizEventDelegate;
  alfizMetric?: AlfizMetricDelegate;
}
```

A generated `PrismaClient` for a schema containing the Alfiz models satisfies this interface automatically. You can also pass a hand-rolled mock for testing:

```ts theme={null}
import type { AlfizPrismaDelegates } from "@alfiz/prisma";

const mockDb: AlfizPrismaDelegates = {
  alfizGrant: { /* ... */ },
  // ...
};

const storage = prismaDriver(mockDb);
```

### Schema fragment (v2)

Add the following models to your `schema.prisma`. These are a fragment, so they have no `datasource` or `generator` blocks and inherit yours. All models are prefixed `Alfiz` to avoid colliding with your own models.

Since 0.8.0 the fragment is **v2**: every model carries an `app` partition discriminator (`@default("")`) and every primary key and index is a composite led by it, so several Applications can [share one set of tables](/operations/shared-store). A single-application deployment changes nothing, because an unpartitioned driver reads and writes partition `""`, which is where a migrated v1 dataset lands. Upgrading from v1 is a column-add plus PK/index rebuilds; see the [0.8.0 changelog entry](/changelog#0-8-0-shared-store-topologies-partitioned-storage-and-the-mesh).

```prisma theme={null}
/// The atomic grant tuple: (subject, role-or-pattern, scope, expiry?).
model AlfizGrant {
  app        String  @default("")
  id         String
  subject    String
  roleId     String?
  pattern    String?
  scope      String
  expiresAt  BigInt?
  provenance Json
  createdAt  BigInt

  @@id([app, id])
  @@index([app, subject])
  @@index([app, scope])
  @@index([app, roleId])
}

/// Personal revoke: the single negative layer; only users hold revokes.
model AlfizRevoke {
  app        String @default("")
  id         String
  userId     String
  pattern    String
  scope      String
  provenance Json
  createdAt  BigInt

  @@id([app, id])
  @@index([app, userId])
  @@index([app, scope])
}

/// A named bundle of permission patterns, plus its requestability policy.
model AlfizRole {
  app         String  @default("")
  id          String
  name        String
  description String?
  patterns    Json
  requestable Json?

  @@id([app, id])
}

/// A user group node; its parent edges live in AlfizGroupParent.
model AlfizGroup {
  app         String  @default("")
  id          String
  name        String
  description String?
  virtual     Boolean @default(false)

  @@id([app, id])
}

/// Group parentage edges (the group DAG).
model AlfizGroupParent {
  app      String @default("")
  childId  String
  parentId String

  @@id([app, childId, parentId])
  @@index([app, parentId])
}

/// The authorization-relevant user record; identity stays in the IdP.
model AlfizUser {
  app           String  @default("")
  userId        String
  active        Boolean
  orgIds        Json
  managerUserId String?

  @@id([app, userId])
}

/// Explicit user → group membership edges.
model AlfizMembership {
  app     String @default("")
  userId  String
  groupId String

  @@id([app, userId, groupId])
  @@index([app, groupId])
}

/// An access request with workflow state and snapshotted approval policy.
model AlfizRequest {
  app               String  @default("")
  id                String
  requesterUserId   String
  roleId            String?
  pattern           String?
  scope             String
  proposedExpiresAt BigInt?
  justification     Json
  state             String
  stageIndex        Int
  stages            Json
  decisions         Json
  createdAt         BigInt
  decidedAt         BigInt?

  @@id([app, id])
  @@index([app, state])
  @@index([app, requesterUserId])
}

/// The published catalog: a versioned singleton per partition (id = 1).
model AlfizCatalog {
  app      String @default("")
  id       Int    @default(1)
  version  Int
  document Json

  @@id([app, id])
}

/// Append-only audit log, ordered by (`at`, `id`) (epoch ms). The hash
/// columns are populated under `audit: { hashChain: true }`.
model AlfizAudit {
  app      String  @default("")
  id       String
  at       BigInt
  actor    String
  action   String
  target   String
  detail   Json?
  prevHash String?
  hash     String?

  @@id([app, id])
  @@index([app, target])
  @@index([app, at, id])
  @@index([app, actor])
}
```

<Note>
  Timestamps (`createdAt`, `expiresAt`, `at`, etc.) are stored as `BigInt` (epoch milliseconds) for lossless round-tripping with the core types' `number` fields. IDs are opaque strings assigned by the Application, with no `autoincrement` or `cuid()` used. No Prisma relations or cascades are declared; referential integrity between Alfiz rows is enforced by the Application layer above the seam.
</Note>

### Optional models

Four more models are additive. Leave them out and everything above still works; add one when you turn on the feature that needs it. Each corresponds to an optional delegate, and **the Application refuses loudly at construction** when you enable a feature whose model is missing.

| Model                       | Enables                                                                     | Without it                                                                                                |
| --------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `AlfizImports`              | `publishImports` / `getPublishedImports`                                    | `capabilities().imports` is `false`; the import methods are absent                                        |
| `AlfizEpoch` + `AlfizEvent` | Event persistence (the 0.7.0 default) and so client revalidation            | Persistence silently stays off under the auto default; an explicit `events: { persist: true }` **throws** |
| `AlfizCatalogVersion`       | Catalog history per publish, for `listCatalogVersions`, `listWildcardDrift` | Drift answers `unsupported`                                                                               |
| `AlfizMetric`               | `metrics: {}` on the Application, `getGrantUsage` and the other usage reads | `createApplication` **throws** when `metrics` is set                                                      |

```prisma theme={null}
/// The consumed-vocabulary manifest, from catalog.toImportManifest().
model AlfizImports {
  app      String @default("")
  id       Int    @default(1)
  version  Int
  manifest Json

  @@id([app, id])
}

/// Catalog history: one retained document per published version.
model AlfizCatalogVersion {
  app         String @default("")
  version     Int
  document    Json
  publishedAt BigInt

  @@id([app, version])
}

/// The invalidation-log head: a singleton per partition (id = 1). One tiny
/// indexed read answers "did anything change anywhere?", which is the
/// cross-process cache-freshness signal behind `revalidateAfterMs`.
model AlfizEpoch {
  app           String @default("")
  id            Int    @default(1)
  seq           BigInt
  prunedThrough BigInt @default(0)

  @@id([app, id])
}

/// Persisted invalidation events, contiguous by `seq` within a partition.
/// the same events the in-process stream carries, durable so other
/// processes can replay them.
model AlfizEvent {
  app     String @default("")
  seq     BigInt
  type    String
  payload Json
  at      BigInt

  @@id([app, seq])
  @@index([app, at])
}

/// Rolling permission-usage counters: one row per counter per time bucket
/// (daily by default), keyed by grant id, revoke id, role id, permission
/// key, or scope type. Counters accumulate across app servers. Nothing here
/// is access data. Dropping the table loses metrics and changes no decision.
model AlfizMetric {
  app       String @default("")
  bucket    BigInt
  dimension String
  subject   String
  metric    String
  count     BigInt

  @@id([app, bucket, dimension, subject, metric])
  @@index([app, dimension, subject])
  @@index([app, bucket])
}
```

## `mongoDriver`

The MongoDB driver ships in `@alfiz/mongo` and implements the seam directly over the official `mongodb` package, so there is no schema to merge and no code generation step. Collections and their hot-path indexes are created lazily on first use; every row is stored with its Alfiz id as Mongo's `_id` for free uniqueness and point reads.

### Installation

```bash theme={null}
npm install @alfiz/mongo mongodb
```

`mongodb` (v6 or v7) is a peer dependency.

### Creating a Mongo driver

```ts theme={null}
import { MongoClient } from "mongodb";
import { createApplication } from "@alfiz/application";
import { mongoDriver } from "@alfiz/mongo";

const client = new MongoClient(process.env.MONGO_URL!);
await client.connect();

const storage = mongoDriver(client.db("alfiz"));
const app = createApplication({ catalog, storage });
```

`mongoDriver` also accepts a `Promise<Db>`, for constructing the Application before `connect()` resolves:

```ts theme={null}
const storage = mongoDriver(client.connect().then((c) => c.db("alfiz")));
```

### `MongoDriverOptions`

<ParamField body="leaseMs" type="number" default="10000" optional>
  Lease duration for the cross-process graph lock, in milliseconds. `runExclusive` is an in-process mutex plus a best-effort lease in the `locks` collection, so two nodes writing group parentage at the same moment cannot jointly form a cycle. There is no `lock` option to configure, unlike the Prisma driver. An expired lease is stolen, so a crashed holder cannot wedge writes forever.
</ParamField>

<ParamField body="partition" type="string" optional>
  Pins the driver to one application's slice of a [shared `Db`](/operations/shared-store); how the partition lands in the database is chosen by `layout`. A separate `Db` per application (`client.db("alfiz_docs")`) needs no option at all and is strictly preferred where databases are not rationed. Must match `[A-Za-z0-9_-]+`.
</ParamField>

<ParamField body="layout" type="&#x22;collections&#x22; | &#x22;rows&#x22;" default="collections" optional>
  How `partition` lands in the shared `Db`. `"collections"` prefixes every collection (`docs_grants`, `docs_epoch`, `docs_locks`, …), which is physically unforgettable but a parallel collection set per application. `"rows"` (since 0.8.1) keeps **one** shared collection set: every document carries an `app` discriminator, `_id` becomes the compound `{ app, id }`, and every index leads with `app`, the same shape as the Prisma v2 tables, so the collection list stays flat however many applications share the `Db`. `"rows"` requires an explicit `partition` (Mongo has no migrated-in-place default dataset the way SQL does), and the rule is one layout per shared `Db`, because a `"rows"` driver cannot see documents written without the discriminator. Both layouts pass the same isolation and mesh conformance suites.
</ParamField>

### What this driver supports

The event-log methods work out of the box, so `events: { persist: true }` and client revalidation need no extra setup, the right posture for serverless. Because the lease already serializes across processes, the driver qualifies for [mesh write edges](/operations/shared-store#write-edges) as shipped. Two features are not implemented: catalog **history** (only the head is kept, so the wildcard-drift report answers `unsupported` rather than wrongly) and the rolling **metrics** methods (`metrics: {}` refuses at construction). Every value the driver assigns into a query document is checked to be the expected primitive first, so an object-shaped value off the wire (`{"subject": {"$ne": null}}`) can never be read as a query operator.

## Partitioned storage: several Applications, one backend

Both database drivers take a `partition` option pinning the driver, at construction and irrevocably, to one application's slice of a shared backend. Isolation is strict and pinned by a published conformance suite; the applications interact in no way unless you opt into the [mesh](/operations/shared-store). The rule in shared tables is **all partitioned or none**: an application that omits the option lands in partition `""` (Prisma) or the unprefixed collections (Mongo) beside any legacy data.

```ts theme={null}
const docs = prismaDriver(prisma, { partition: "docs", lock });
const billing = prismaDriver(prisma, { partition: "billing", lock });
// or, over one Mongo Db: prefix-per-partition, or one shared collection set:
const docs = mongoDriver(db, { partition: "docs" });
const docs = mongoDriver(db, { partition: "docs", layout: "rows" });
```

See [Shared-store topologies](/operations/shared-store) for the full picture: what collides without partitioning, the mesh's access edges, and the org partition.

## Multi-node deployments

The default in-process mutex inside the memory driver and the Prisma driver only serializes within one Node.js process. When you run multiple application instances against the same database, two concurrent group-parentage or reporting-edge writes could each pass their individual cycle checks while jointly forming a cycle. Supply the Prisma driver's `lock` option with a database advisory lock to prevent this; the Mongo driver's lease covers it as shipped.

## Implementing a custom driver

If you use a database without a bundled driver, such as DynamoDB or a raw SQL driver, implement `StorageDriver` directly. The interface is in `@alfiz/application`:

```ts theme={null}
import type { StorageDriver } from "@alfiz/application";

export function myCustomDriver(): StorageDriver {
  return {
    async insertGrant(row) { /* ... */ },
    async deleteGrant(id) { /* ... */ },
    // ... all other methods
    async runExclusive(key, fn) { /* advisory lock or mutex */ },
  };
}
```

Conventions a custom driver has to get right, and since 0.7.0 a published conformance suite that checks them for you:

```ts theme={null}
// my-driver.test.ts, the whole file
import { test } from "vitest"; // or any runner; the cases use node:assert
import {
  driverContractCases,
  eventLogContractCases,
  metricsContractCases,
} from "@alfiz/application/driver-suite";

for (const c of driverContractCases) test(c.name, () => c.run(makeDriver()));
```

The suite covers the row round-trips, the audit ordering and filters below, catalog history, and the case that matters most for correctness, **`runExclusive` serialization**, which is what keeps graph-cycle detection sound under concurrency. A driver that fails it is unsound multi-node, not merely non-conforming.

Since 0.8.0 the suite also exports `isolationContractCases(makePartitionedDriver)` and `meshContractCases(makePartitionedDriver)`. A custom driver claiming multi-application support over one backend is graded against the same isolation bar the bundled drivers pass, including the failure directions that leak silently: the unfiltered lists, the singletons, event-log sequencing, and the audit-chain seed. See [Shared-store topologies](/operations/shared-store).

The conventions the suite pins:

* **Absent rows return `null`**, never `undefined` and never a thrown error. Both bundled drivers follow this.
* **Audit order is (`at`, then `id`)**, and `listAudit({ limit })` returns the NEWEST `limit` events in that order rather than the oldest, while `listAudit({ cursor })` returns the first `limit` strictly *after* the cursor, ascending, for export paging. The memory driver slices from the end; the Prisma driver orders ascending and takes `-limit`.
* **Catalog history is optional but honest**: implement `getCatalogVersion`/`listCatalogVersions` (and retain a row per `putCatalog`) to enable the [wildcard-drift report](/operations/access-reviews#wildcard-drift-what-changed-since-you-certified); omit them and the report answers `unsupported` rather than wrongly.
