Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 40 additions & 29 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ making changes. For contribution *process/terms* see

## What this is

`@imqueue/pg-prisma` is the Prisma/Postgres persistence toolkit of the @imqueue
framework. It provides Prisma client extensions, Postgres operational helpers,
and a Prisma generator that emits typed
[`@imqueue/rpc`](https://github.com/imqueue/rpc) model & repository classes.
The generated model classes validate their inputs with
[`@imqueue/validation`](https://github.com/imqueue/validation).
`@imqueue/pg-prisma` is the Prisma Next (8.x) / Postgres persistence toolkit of
the @imqueue framework. It provides query middlewares that rewrite a statement
before it is lowered to SQL, and Postgres operational helpers.

There is no code generator. Prisma Next emits `contract.json`, and
`deriveDataLayer` reads the per-model configuration straight out of it, so
nothing is written to disk and nothing can drift from the schema.

## Toolchain & invariants (do not fight these)

Expand All @@ -24,15 +25,16 @@ The generated model classes validate their inputs with
- **TypeScript, `module`/`moduleResolution: nodenext`**, `target: es2024`,
`verbatimModuleSyntax: true`, `isolatedModules: true`, `strict: true`. Use
`import type` / `import { type X }` for type-only imports.
- **Node ≥ 22.12. Prisma 7+.**
- **`@prisma/client` is a peer dependency.** The extension modules import the
`Prisma` namespace / `PrismaClient` type from **`@prisma/client/extension`** —
the official entry for *shareable* Client extensions. It resolves without
running `prisma generate` and works no matter where the consumer generates
their client (default `@prisma/client` output or a custom output path), so
this package needs no schema or generated client of its own to build. Runtime
deps are kept minimal: `pg` (down-migrations) and `@prisma/generator-helper`
(the generator). Do not add heavyweight deps.
- **Node ≥ 22.12. Prisma Next (8.x).**
- **`@prisma/orm-postgres` is a peer dependency.** The middlewares import AST
constructors and types from **`@prisma/orm-postgres/relational-core/ast`**.
That subpath resolves without emitting a contract, so this package needs no
schema of its own to build. The single runtime dep is `pg`, for the audit
trail's own pool. Do not add heavyweight deps.
- **Use the real AST types.** `AnyQueryAst` is a discriminated union on `kind`;
narrowing on it gives `table`, `set`, `rows` and `returning` their proper
types. A hand-rolled structural type here costs the one check that catches a
mistake.
- **Lint/format:** `oxlint` + `oxfmt`. Run `npm run format` before committing;
CI checks `npm run format:check`.
- Build **emits `.js`/`.d.ts`/`.js.map` next to sources**; these are
Expand All @@ -53,27 +55,28 @@ npm run test-coverage # tests + experimental coverage summary
npm run test-lcov # writes coverage/lcov.info
```

Unit tests (`test/**/*.spec.ts`, run compiled) cover the pure helpers
(`prettifySql`, `accessWhere`). The extension and installer modules that touch a
live database are exercised by the consuming service's integration suite, not
here.
Unit tests (`test/**/*.spec.ts`, run compiled) cover the middlewares by
constructing AST nodes directly — no database is needed to assert what a
statement was rewritten into, and that is where the bugs have been. The
installer modules that touch a live database are exercised by the consuming
service's integration suite, not here.

## Layout

| Path | Role |
|---|---|
| `index.ts` | Public entry: `export * from './src/index.js'` |
| `src/index.ts` | Barrel re-exporting the public API |
| `src/soft-delete.ts` | Prisma soft-delete query extension. |
| `src/audit.ts` | Prisma audit-trail query extension. |
| `src/authorship.ts` | Prisma authorship-stamping query extension. |
| `src/access-scope.ts` | `accessWhere()` row-level access-scope filter composer. |
| `src/ast.ts` | AST helpers; `filterSelects()` walks every select in a statement. |
| `src/derive.ts` | `deriveDataLayer()` — per-table config read from `contract.json`. |
| `src/data-layer.ts` | `dataLayer()` — the composed middleware array, in one call. |
| `src/stamp.ts` | Soft-delete and authorship, as one middleware. |
| `src/access-scope.ts` | Row-level access-scope middleware. |
| `src/audit.ts` | Audit-trail middleware, writing through its own pool. |
| `src/archive.ts` | Row-archiving installer (aged rows → mirror `archive` schema, pg_cron). |
| `src/change-notify.ts` | Postgres row-change `NOTIFY` trigger installer. |
| `src/migrate-down.ts` | `migrateDown()` — undo applied Prisma migrations; also a CLI. |
| `src/pretty-sql.ts` | `prettifySql()` SQL pretty-printer for query logging. |
| `src/sql-log.ts` | Cooperative SQL-log suppression (`silently`, `isSqlLogSuppressed`). |
| `src/codegen.ts` | Prisma generator: emits typed `@imqueue/rpc` models & repositories. |
| `test/**` | `node:test` specs (`*.spec.ts`). |

## Behavioural invariants
Expand All @@ -87,10 +90,18 @@ here.
`#prisma`, RPC decorators from `@imqueue/rpc`, and validation decorators from
`@imqueue/validation`. Keep those import strings stable — they are the
generator's output contract.
- **`migrateDown()` is side-effect-pure at import time.** The generator and the
migrate-down CLI only run when their module is executed directly
(`import.meta.url === argv[1]`); importing the package barrel must have no side
effects and must not require the dev-only `@prisma/generator-helper`.
- **Filter the whole statement, never just its root.** Prisma Next compiles a
relation read into one statement holding several selects. A predicate applied
only to the outermost `from` returns the rows it was meant to exclude,
through any `include`, with nothing logged. Use `filterSelects()`.
- **Qualify a column by the source's alias when it has one.** `TableSource`
renders as `"public"."Session" AS "s"`, and a column qualified by the table
name is then not in scope; Postgres rejects the statement.
- **Buffer audit rows per execution, not per client.** `onRow` runs inside an
async generator, so concurrent statements on one client interleave at every
row. A shared buffer lets one statement flush another's rows under the wrong
actor — the worst failure a security trail has. Key by the plan object, and
resolve the actor at the first row, inside that statement's async context.
- **`silently()` flips a shared module flag** — it is for pre-request one-offs
(startup DDL), not interleaved concurrent traffic.

Expand Down
188 changes: 123 additions & 65 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
[![npm version](https://img.shields.io/npm/v/@imqueue/pg-prisma)](https://www.npmjs.com/package/@imqueue/pg-prisma)
[![License](https://img.shields.io/badge/license-GPL-blue.svg)](https://github.com/imqueue/pg-prisma/blob/master/LICENSE)

A Prisma/Postgres toolkit for Node.js & TypeScript back-ends — the persistence
helpers behind @imqueue framework services. It bundles a set of Prisma
[client extensions](https://www.prisma.io/docs/orm/prisma-client/client-extensions)
(soft-delete, audit trail, authorship stamping, row-level access scope), Postgres
operational helpers (row archiving, change-notify triggers, down-migrations, SQL
log formatting), and a Prisma generator that emits typed
[@imqueue/rpc](https://github.com/imqueue/rpc) model & repository classes.
A Prisma Next (8.x) / Postgres toolkit for Node.js & TypeScript back-ends — the
persistence helpers behind @imqueue framework services. It bundles a set of
Prisma Next query **middlewares** (soft-delete, authorship stamping, audit
trail, row-level access scope) that rewrite the statement before it is lowered
to SQL, plus Postgres operational helpers (row archiving, change-notify
triggers, SQL log formatting).

Their per-model configuration is **derived from the emitted `contract.json`**
rather than generated: Prisma Next has no custom-generator protocol and needs
none, since the contract already names every model, field and physical column.

**Documentation:** full guides, tutorial and API reference at
[imqueue.org](https://imqueue.org/). Commercial licensing & support for
Expand All @@ -32,93 +35,148 @@ version, licence and Node floor for every package:

# Features

- **Soft-delete extension** — transparently excludes soft-deleted rows and turns
deletes into `deletedAt` stamps.
- **Audit extension** — writes an append-only audit trail of INSERT/UPDATE/DELETE.
- **Authorship extension** — stamps `createdBy`/`updatedBy`/`deletedBy` from a
caller-supplied actor id.
- **Access-scope helper** — `accessWhere(...)` composes row-level access filters
(AND of per-level OR groups) onto any Prisma `where`.
- **Row archiving** — moves aged rows out of hot tables into a mirror `archive`
schema on a pg_cron schedule (idempotent DB setup).
- **Change-notify triggers** — installs Postgres `NOTIFY` triggers for row
changes, reporting the schema alongside the table so tables of the same name
in different schemas are told apart. `withoutChangeNotify()` lets a bulk
write commit without a notification per row.
- **Down-migrations** — `migrateDown()` undoes applied Prisma migrations (Prisma
has no native "down").
- **SQL log helpers** — `prettifySql()` and cooperative log suppression.
- **Prisma generator** — emits typed `@imqueue/rpc` models, inputs, query types
and repositories from your schema.
- **TypeScript included!**
- **Soft delete and authorship** — a `DELETE` becomes a `deletedAt` stamp,
stamped rows disappear from reads, and every write records who made it.
- **Access scope** — every read, update and delete is narrowed to the rows the
caller may see, in the data layer rather than at each call site.
- **Audit trail** — every write to a nominated table recorded with the actor,
the action and the row as the database returned it.
- **Row archiving** — aged rows moved into a mirror `archive` schema on a
pg_cron schedule.
- **Change-notify triggers** — Postgres `NOTIFY` on every row change.

Filtering applies across the **whole statement**, not just its outermost
`FROM`. Prisma Next compiles a relation read into one statement holding several
selects, so a filter on the root alone would return soft-deleted and
out-of-scope rows through any `include`.

# Requirements

- Node.js ≥ 22.12, PostgreSQL, and Prisma **7+**.
- `@prisma/client` is a **peer dependency** — the extensions import the `Prisma`
namespace from `@prisma/client/extension` (the entry for shareable Client
extensions), so they work whether you generate your client to the default
`@prisma/client` output or to a custom path.
- Some Postgres features are optional: row archiving schedules via `pg_cron` when
available (it degrades gracefully when the extension is absent).
- Node.js >= 22.12
- `prisma` 8.x and `@prisma/orm-postgres` (peer dependency)
- PostgreSQL 15 or newer

# Install

```bash
npm i --save @imqueue/pg-prisma
npm i @imqueue/pg-prisma
```

# Usage

## Client extensions
## The data layer, in one call

```typescript
import { PrismaClient } from '@prisma/client';
import { softDelete, audit, authorship } from '@imqueue/pg-prisma';

// The FIRST-added extension is the OUTERMOST — keep `audit` first so that
// soft-deletes are still recorded in the audit trail.
const prisma = new PrismaClient()
.$extends(audit({ /* ...config... */ }))
.$extends(authorship({ /* ...config... */ }))
.$extends(softDelete({ /* ...config... */ }));
import { dataLayer } from '@imqueue/pg-prisma';
import postgres from '@prisma/orm-postgres/runtime';
import type { Contract } from './prisma/contract.d.ts';
import contractJson from './prisma/contract.json' with { type: 'json' };

const layer = dataLayer({
contract: contractJson,
scope: { Portfolio: { portfolio: ['id'] } },
resolvers: { portfolio: () => currentPortfolioIds() },
getActorId: currentActorId,
audit: {
connectionString: process.env.DATABASE_URL!,
config: { table: 'AuditLog', columns: { /* ... */ } },
getPrincipal: currentPrincipal,
},
});

export const db = postgres<Contract>({
contractJson,
url: process.env.DATABASE_URL!,
middleware: layer.middleware,
});
```

`dataLayer` returns the middlewares already composed. That is the point: a
caller never orders them, and so cannot order them wrongly. Call
`layer.close()` on shutdown to release the audit pool.

## Access scope

Scope is the one thing that cannot be derived from the contract — Prisma Next
has no schema-level annotation to carry it — so it is declared where
`dataLayer` is called, keyed by model and field:

```typescript
import { accessWhere } from '@imqueue/pg-prisma';
scope: {
Portfolio: { portfolio: ['id'] },
User: { user: ['createdBy', 'id'] },
}
```

const where = accessWhere(
callerWhere,
{ user: ['createdBy'], portfolio: ['portfolioId'] },
{ user: () => currentUserId, portfolio: () => allowedPortfolioIds },
);
Columns **within** one level are OR-ed; levels are AND-ed together. A resolver
returning `undefined` leaves its level inactive, a value or array restricts,
and `null` or an empty array denies everything. Get the composition backwards
and the failure is a data leak rather than an error, so a `scope` naming a
model the contract does not define is a throw, not a silent no-op.

## Emitting the RPC model classes

Prisma Next emits `contract.d.ts`, which carries the types but not the
decorated classes. `@classType`/`@property` are what the
[@imqueue/rpc](https://github.com/imqueue/rpc) client generator reads, and an
undecorated type is dropped from the generated client with no error — so the
DTO classes are emitted here, from the same contract:

```typescript
import { emitModels, parseImportMap } from '@imqueue/pg-prisma';

await writeFile('src/generated/models.ts', emitModels({ contract }));
```

## Prisma generator
### Redirecting the runtime imports

The package ships a Prisma generator that emits typed `@imqueue/rpc` model and
repository classes. Point a generator block at it in your `schema.prisma`:
By default the emitted file imports `@imqueue/rpc` directly. Pass `imports` to
point it somewhere else:

```prisma
generator imq {
provider = "node ./node_modules/@imqueue/pg-prisma/src/codegen.js"
}
```typescript
emitModels({
contract,
imports: parseImportMap('@imqueue/rpc=@my-org/runtime'),
});
```

```typescript
// before
import { classType, property } from '@imqueue/rpc';

// after
import { classType, property } from '@my-org/runtime';
```

The generated code assumes your project defines the subpath import aliases
`#generated/*` and `#prisma` (your `PrismaClient` instance), and imports
validation decorators from `@imqueue/validation` and RPC decorators from
`@imqueue/rpc`. Install those alongside this package if you use the generator.
**Why this exists.** The decorators are only meaningful to the registry that
defined them, so `@imqueue/rpc`, `@imqueue/validation` and `zod` each have to
be a **single copy** shared with the service. A second copy fails silently
rather than loudly — a second decorator registry nothing reads, or a `ZodError`
that fails `instanceof`. The reliable way to guarantee one copy is for one
package to own the dependency and re-export it, with every service taking it
from there; redirecting the emitted imports is what makes that possible.

## Down-migrations
Redirecting several runtimes at one package merges them into a single
statement, rather than emitting the same specifier three times:

```bash
node --import tsx node_modules/@imqueue/pg-prisma/src/migrate-down.js \
--database-url "$DATABASE_URL" --steps 1
```typescript
parseImportMap(
'zod=@base, @imqueue/rpc=@base, @imqueue/validation=@base',
);
// import { classType, property, validatable, validate, z } from '@base';
```

Redirecting a module the generator never emits throws rather than being
ignored, because the alternative is believing a redirection was applied while
the generated files still point at the original.

## Composing it yourself

`stamp`, `accessScope` and `audit` are exported individually for cases
`dataLayer` does not cover, and `deriveDataLayer` produces the config they take.
The middlewares commute — `stamp` merges what were two order-dependent Prisma 7
extensions — so there is no required order between them.

## Running Unit Tests

Tests run on the native Node.js test runner (`node:test`) with `node:assert` and
Expand Down
44 changes: 23 additions & 21 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,32 +23,34 @@
*/

/**
* Prisma and Postgres building blocks for `@imqueue` services.
* Prisma Next (8.x) and Postgres building blocks for `@imqueue` services.
*
* Two kinds of thing live here, and they are used at different times.
*
* Query extensions wrap a `PrismaClient` and change what queries do:
* `softDelete` turns deletes into `deletedAt` stamps and hides stamped rows,
* `accessScope` narrows every read to the records the caller is allowed to see,
* `authorship` stamps who created, updated or deleted a row, `audit` writes a
* trail of every write to a table you nominate, and `isoDates` serializes `Date`
* values so they survive the RPC wire as ISO strings. Each is independent; each is
* driven by a per-model config the code generator emits from your Prisma schema.
* Query middlewares rewrite the statement before it is lowered to SQL:
* `stamp` turns deletes into `deletedAt` stamps, hides stamped rows and records
* who created, updated or deleted a row; `accessScope` narrows every read to
* the records the caller is allowed to see; and `audit` writes a trail of every
* write to a table you nominate. `dataLayer` builds all three from an emitted
* contract in one call and is the entry point for the ordinary case — the
* individual factories are there to compose something it does not cover.
*
* Installers and tools run once at startup or by hand rather than per query:
* `installArchiving` moves aged rows into a mirror `archive` schema on a pg_cron
* schedule, `installChangeTriggers` makes Postgres `NOTIFY` on every row change,
* `migrateDown` rolls applied migrations back (Prisma has no native "down"), and
* `prettifySql`/`silently`/`isSqlLogSuppressed` are query-logging helpers.
*
* Ordering matters when extensions are combined, because Prisma runs the
* first-added query hook outermost. `audit` has to be added first if it is to see
* operations that `softDelete` reroutes; the individual pages say so where it
* applies.
*
* The Prisma generator that emits typed `@imqueue/rpc` models from your schema
* ships separately at `@imqueue/pg-prisma/codegen` and is invoked by Prisma, not
* imported — it is deliberately absent from this barrel.
* `installArchiving` moves aged rows into a mirror `archive` schema on a
* pg_cron schedule, `installChangeTriggers` makes Postgres `NOTIFY` on every
* row change, and `prettifySql`/`silently`/`isSqlLogSuppressed` are
* query-logging helpers.
*
* The per-model configuration is **derived from `contract.json`** by
* `deriveDataLayer` rather than generated: Prisma Next has no custom-generator
* protocol and needs none, since the contract already names every model, field
* and physical column. Nothing is written to disk and nothing can go stale
* against the schema. Access levels are the one thing that cannot be derived —
* Prisma Next has no schema annotation to carry them — so they are declared
* where `dataLayer` is called.
*
* The middlewares commute: `stamp` merges what were two order-dependent
* Prisma 7 extensions, so there is no ordering left for a caller to get wrong.
*
* @packageDocumentation
*/
Expand Down
Loading
Loading