@alfiz/prisma
v0.8.1
Published
The Prisma storage driver for the Alfiz Application: implements the storage seam over any Prisma client generated from the bundled schema fragment, with @prisma/client kept out of the dependency graph via a structural delegate interface.
Readme
@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 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:
npx prisma migrate dev
npx prisma generateSince 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
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
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):
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).
