# @alfiz/prisma

The Prisma storage driver for the Alfiz Application. It implements the
storage seam (`StorageDriver` from `@alfiz/application`) over a Prisma
client — and does it without depending on `@prisma/client`: the driver is
written against a structural interface (`AlfizPrismaDelegates`) that any
client generated from the bundled schema fragment satisfies.

## 1. Merge the schema fragment

Copy the models from [`prisma/schema.prisma`](./prisma/schema.prisma) into
your application's own `schema.prisma` (they are a fragment — no datasource
or generator blocks — and all models are prefixed `Alfiz` to avoid
collisions), then migrate and generate as usual:

```sh
npx prisma migrate dev
npx prisma generate
```

Since 0.8.0 the fragment is **v2**: every model carries an `app`
partition discriminator (`@default("")`) with composite keys led by it,
so several Applications can share one set of tables. A single-application
deployment changes nothing — an unpartitioned driver reads and writes
partition `""`, which is where a migrated v1 dataset lands. Upgrading
from the v1 fragment is a column-add plus PK/index rebuilds; see
`docs/MIGRATING.md` §12 in the repository root.

## 2. Construct the driver

```ts
import { PrismaClient } from "@prisma/client";
import { createApplication } from "@alfiz/application";
import { prismaDriver } from "@alfiz/prisma";

const prisma = new PrismaClient();
const storage = prismaDriver(prisma); // structural match — no adapter, no cast
const app = createApplication({ storage /* ... */ });
```

That no-cast promise is pinned in CI by a compile-only fixture
(`src/prisma-client-shape.ts`) replicating the exact input types
`prisma generate` emits — Json inputs that reject bare `null`,
`bigint | number` scalars, Prisma-style optional properties — so a
delegate-surface change that would force `as unknown as
AlfizPrismaDelegates` on adopters fails this package's own build instead.
The match holds under `exactOptionalPropertyTypes` too.

## The invalidation log (AlfizEpoch / AlfizEvent)

The fragment includes two models backing the Application's
`events: { persist: true }` option — the persisted invalidation log that
lets clients on OTHER processes revalidate their caches with one
single-row read (`AlfizEpoch`) instead of waiting out a TTL. They are
additive: merge them and `prisma migrate dev` as usual; the epoch row is
lazily created on first append, no seed required. A client generated
WITHOUT them still satisfies the driver interface — the driver then omits
the optional event methods and `events.persist` refuses at construction.

## Permission metrics (AlfizMetric)

One more additive model backs the Application's `metrics: {}` option:
rolling counter buckets, keyed by `(bucket, dimension, subject, metric)`
and incremented by upsert, so every app server reporting a window sums into
the same numbers. Storage is bounded by attributed rows × retention ÷
bucket size, and compaction is a `deleteMany` past the retention cutoff.
Like the log models, a client generated WITHOUT it still satisfies the
driver interface — the driver omits the metric methods and the Application
refuses `metrics` at construction rather than accepting batches that go
nowhere. Nothing in this table is access data: dropping it loses counts and
changes no decision.

## Partitioned storage: several Applications, one set of tables

```ts
const docs = prismaDriver(prisma, { partition: "docs", lock });
const zoom = prismaDriver(prisma, { partition: "zoom", lock });
```

Each driver is pinned to its partition at construction and cannot address
any other. The discriminator is *unforgettable by construction*: the
delegate types require `app` on every where and create shape, so a query
inside the driver that omitted the partition would fail to compile rather
than scan every tenant — pinned by the same compile-only fixture as the
no-cast promise. The recommended partition key is the application's
primary catalog namespace, and the rule in shared tables is **all
partitioned or none**: an application that omits the option lands in
partition `""` beside any legacy data.

Isolation here is logical and cooperative — every co-tenant holds
credentials to the whole table set. When isolation must hold against a
compromised co-tenant rather than against bugs, prefer
schema-per-application via the connection string (`?schema=docs` on
Postgres; a separate database on MySQL) with separate credentials: one
`PrismaClient` per app, zero schema changes, isolation enforced by the
database. Grading a shared-table deployment (or your own driver) is what
`isolationContractCases` / `meshContractCases` in
`@alfiz/application/driver-suite` exist for.

## Multi-node deployments: pass an advisory lock

`runExclusive` defaults to an in-process mutex, which serializes graph
writes within one process only. If several nodes share the database, supply
a database advisory lock so two nodes cannot jointly write a graph cycle —
and, with event persistence on, so two nodes cannot interleave sequence
allocation in the invalidation log (event appends serialize under the same
lock, key `alfiz:events`):

```ts
const storage = prismaDriver(prisma, {
  lock: (key, fn) =>
    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 co-tenants sharing one
database never contend on each other's advisory locks. Supplying `lock`
is also what marks the driver cross-process capable
(`StorageDriver.crossProcess`) — a prerequisite for accepting a mesh
WRITE edge into the partition, where a peer Application is a second
process executing this partition's semantics (`openPeerApplication` in
write mode refuses without it, loudly).

## What lives where

The driver stores and retrieves; it never interprets. All ids are opaque
strings assigned by the Application layer, which also owns graph integrity,
request workflows, catalog versioning, and the audit log. Epoch-ms
timestamps are stored as `BigInt` columns for lossless round-tripping;
optional core fields map to nullable columns (`undefined` ↔ `NULL`).
