@12-apps/prisma 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +95 -0
  2. package/dist/actor-context.d.ts +127 -0
  3. package/dist/actor-context.js +139 -0
  4. package/dist/append-only-extension.d.ts +28 -0
  5. package/dist/append-only-extension.js +72 -0
  6. package/dist/audit-extension.d.ts +18 -0
  7. package/dist/audit-extension.js +123 -0
  8. package/dist/index.d.ts +46 -0
  9. package/dist/index.js +213 -0
  10. package/dist/search-normalize.d.ts +13 -0
  11. package/dist/search-normalize.js +20 -0
  12. package/package.json +96 -0
  13. package/prisma/migration-files.ts +33 -0
  14. package/prisma/migrations/20260725150000_payments_platform_core/migration.sql +98 -0
  15. package/prisma/migrations/20260725160000_payments_oauth_connections/migration.sql +25 -0
  16. package/prisma/migrations/20260725170000_add_saved_reports/migration.sql +22 -0
  17. package/prisma/migrations/20260726090000_payments_multi_provider_failover/migration.sql +67 -0
  18. package/prisma/migrations/20260726120000_payments_failover_policy/migration.sql +29 -0
  19. package/prisma/migrations/20260726130000_add_report_lifecycle/migration.sql +18 -0
  20. package/prisma/migrations/20260727120000_add_report_archived_status/migration.sql +9 -0
  21. package/prisma/migrations/20260727120000_payments_webhook_replay_budget/migration.sql +37 -0
  22. package/prisma/migrations/20260727190000_sweep_leases/migration.sql +13 -0
  23. package/prisma/migrations/20260728120000_add_product_research/migration.sql +112 -0
  24. package/prisma/migrations/20260728170000_add_manual_price_entries/migration.sql +31 -0
  25. package/prisma/migrations/20260729090000_integration_source_singleton/migration.sql +24 -0
  26. package/prisma/migrations/20260729120000_price_source_soft_delete/migration.sql +24 -0
  27. package/prisma/migrations/20260729140000_research_term_normalized/migration.sql +26 -0
  28. package/prisma/migrations/20260730120000_offer_outside_delivery_area/migration.sql +16 -0
  29. package/prisma/migrations/20260730120000_payments_charge_verified_at/migration.sql +23 -0
  30. package/prisma/migrations/20260730130000_research_runs_created_at_index/migration.sql +12 -0
  31. package/prisma/migrations/20260730210000_add_shifts/migration.sql +95 -0
  32. package/prisma/migrations/20260731000000_offer_shipping_unknown/migration.sql +34 -0
  33. package/prisma/migrations/20260731210000_shift_delete_guard/migration.sql +43 -0
  34. package/prisma/migrations/20260810120000_add_report_default_range/migration.sql +15 -0
  35. package/prisma/migrations/20260810160000_report_default_range_month/migration.sql +15 -0
  36. package/prisma/migrations/20260810180000_add_report_working_copy/migration.sql +13 -0
  37. package/prisma/plugin-migrations.json +28 -0
  38. package/prisma/schema/entity-lifecycle.prisma +126 -0
  39. package/prisma/schema/jobs.prisma +48 -0
  40. package/prisma/schema/product-research.prisma +217 -0
  41. package/prisma/schema/schema.prisma +19 -0
  42. package/prisma/schema/shift.prisma +34 -0
  43. package/src/actor-context.ts +218 -0
  44. package/src/append-only-extension.ts +75 -0
  45. package/src/audit-extension.ts +121 -0
  46. package/src/index.ts +233 -0
  47. package/src/search-normalize.ts +19 -0
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @12-apps/prisma
2
+
3
+ The repo's **Prisma host**. Everything Prisma-shaped lives here and nowhere
4
+ else: the multi-file schema folder, the migrations folder, the sync scripts that
5
+ pull plugin-owned models and migrations in, and the runtime `PrismaClient`
6
+ singleton with its audit / append-only extensions.
7
+
8
+ It was carved out of `@12-apps/shared-helpers`, which is a bag of generic
9
+ utilities (S3, caching, requests, money, dates) and had no business owning a
10
+ database schema. Splitting it means a consumer that wants `formatMoney` no
11
+ longer installs `@prisma/client`, PGlite and a WASM Postgres to get it.
12
+
13
+ ```
14
+ packages/prisma/
15
+ prisma/
16
+ schema/ # datasource + generator, plus the plugin partials
17
+ migrations/ # every committed migration, host- and plugin-owned
18
+ plugin-migrations.json # generated: which migrations came from a plugin
19
+ migration-files.ts # side-effect-free migration discovery
20
+ prisma.config.ts # Prisma 7 config (schema/migrations paths, datasource)
21
+ scripts/ # the sync + verification scripts
22
+ src/ # the runtime client and its extensions
23
+ ```
24
+
25
+ ## What it exports
26
+
27
+ ```ts
28
+ import {
29
+ getPrismaClient,
30
+ setPrismaClient,
31
+ resetPrismaClient,
32
+ runWithActor,
33
+ setActor,
34
+ getActorUserId,
35
+ getActorAttribution,
36
+ normalizeSearchText,
37
+ AppendOnlyViolationError,
38
+ type PrismaClient,
39
+ } from '@12-apps/prisma';
40
+ ```
41
+
42
+ `getPrismaClient()` is lazy and memoised on `globalThis`, so Next dev /
43
+ Turbopack hot-reload never spawns a second PGlite instance. It builds a
44
+ PostgreSQL-backed client by default and a PGlite-backed one when `USE_FILE_DB`,
45
+ `PGLITE_DATA_DIR` or a `pglite:` `DATABASE_URL` selects it — never in
46
+ production unless `USE_FILE_DB=1` is explicit.
47
+
48
+ Server-only. `AsyncLocalStorage` and PGlite are Node APIs, and Prisma is not
49
+ Edge-safe, so nothing here may be imported (directly or transitively) from
50
+ middleware or an Edge-runtime route.
51
+
52
+ ## The plugin seam
53
+
54
+ Packages that own persisted models keep the model file **and its migrations** in
55
+ their own folder, and this package pulls them in:
56
+
57
+ | Owner | Partial | Pulled in by |
58
+ |---|---|---|
59
+ | `@12-apps/entity-lifecycle` | `entity-lifecycle.prisma` | `scripts/sync-lifecycle-schema.mjs` |
60
+ | `@12-apps/product-research` | `product-research.prisma` | `scripts/sync-research-schema.mjs` |
61
+ | `@12-apps/shift` | `shift.prisma` | `scripts/sync-shift-schema.mjs` |
62
+ | `@12-apps/jobs` | `jobs.prisma` | `scripts/sync-jobs-schema.mjs` |
63
+ | `@12-apps/payments-backend` | `payments.prisma` | committed symlink |
64
+ | `@12-apps/report-builder` | `report-builder.prisma` | committed symlink |
65
+
66
+ Migrations travel separately, through `scripts/sync-prisma-plugins.mjs`, which
67
+ discovers every plugin-owned `migrations` directory **structurally** rather than
68
+ from a hardcoded list.
69
+
70
+ `prisma/schema/schema.prisma` is **datasource + generator only** — this repo
71
+ deliberately owns no domain models, and no seed command either. The consuming
72
+ application supplies both.
73
+
74
+ Two rules that came from production incidents, gated by `package.test.ts`:
75
+
76
+ - **Migrations are copied, never symlinked.** Prisma enumerates the migrations
77
+ folder with `lstat`, so a symlinked migration reports `isDirectory() === false`
78
+ and is silently skipped — a green deploy that changed no schema.
79
+ - **A partial's owning package must be a declared workspace dependency.**
80
+ `turbo prune` copies only what the dependency graph reaches; an undeclared
81
+ owner is dropped from the build context, the committed partial's source
82
+ vanishes, and its sync script exits 1 during the image build.
83
+
84
+ ## Scripts
85
+
86
+ ```bash
87
+ pnpm --filter @12-apps/prisma prisma:generate # sync (check mode) + generate
88
+ pnpm --filter @12-apps/prisma prisma:migrate # prisma migrate dev
89
+ pnpm --filter @12-apps/prisma prisma:sync-plugins # repair plugin migration copies
90
+ pnpm --filter @12-apps/prisma prisma:sync-lifecycle # repair one partial
91
+ ```
92
+
93
+ `build` and `prisma:generate` run every sync in `--check` mode: they **verify**
94
+ the committed state and never repair it, so a CI gate can never assert against
95
+ a tree it just fixed. Repair is always explicit.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Per-request "who is acting" context (FUT-168), backed by Node's
3
+ * AsyncLocalStorage. The auth layer sets the current admin's `users.id` once a
4
+ * request is authorized; the Prisma audit extension (see `audit-extension.ts`)
5
+ * reads it to auto-stamp `created_by` / `updated_by` on tracked models — so no
6
+ * repository signature or call site has to thread the actor through by hand.
7
+ *
8
+ * FUT-152 enriches the context with the ROLE and SCOPE the request was
9
+ * authorized under, so audit entries can record not just who acted but under
10
+ * which authority — populated by the same guards that stamp the user id.
11
+ *
12
+ * FUT-458 adds a SECOND identity: the subject a request is being rendered as
13
+ * while a super-admin impersonation or a "Ver como" preview is live. It is
14
+ * stored as a PAIR — the subject plus the real human behind it — and the
15
+ * second half of that pair is written by this module alone. See
16
+ * {@link ActorAttributionSnapshot.realUserId} for why the real human cannot
17
+ * simply be read back out of {@link ActorContext.userId}.
18
+ *
19
+ * Server-only: AsyncLocalStorage is a Node API. Never import from the Edge
20
+ * middleware runtime.
21
+ */
22
+ /** Role/scope authority attribution a caller may STAMP (FUT-152). */
23
+ export interface ActorAttribution {
24
+ /** The role name the request was authorized under (e.g. `ADMIN`), if known. */
25
+ role?: string;
26
+ /** The scope the authorization decision was made in (tenant id / `GLOBAL`). */
27
+ scope?: string;
28
+ /**
29
+ * The DB `users.id` this request is being rendered AS (FUT-458) — a
30
+ * super-admin impersonation target, or a "Ver como" previewed member.
31
+ *
32
+ * NEVER the actor: {@link ActorContext.userId} stays the real human whose
33
+ * credentials authorized the request, and this is recorded ALONGSIDE it. The
34
+ * audit trail must be able to answer "who really did this" and "who did the
35
+ * screen claim to be" independently, and a single field cannot.
36
+ *
37
+ * `undefined` leaves an existing value untouched (see {@link setActor}'s merge
38
+ * rule); pass `null` to CLEAR it explicitly. Merge semantics make the
39
+ * distinction load-bearing here — an impersonation that cannot be cleared
40
+ * would leak onto every later write in the same request.
41
+ */
42
+ onBehalfOfUserId?: string | null;
43
+ }
44
+ /**
45
+ * What {@link getActorAttribution} hands back: everything a caller may stamp,
46
+ * plus the one field only this module ever writes.
47
+ */
48
+ export interface ActorAttributionSnapshot extends ActorAttribution {
49
+ /**
50
+ * The REAL human behind a live impersonation (FUT-458) — captured from the
51
+ * same stamp that declared it, and absent/`null` when no impersonation is
52
+ * live. Deliberately NOT part of {@link ActorAttribution}: a caller cannot
53
+ * pass it, only this module derives it.
54
+ *
55
+ * Why it exists rather than "just read {@link ActorContext.userId}": that
56
+ * field is LAST-WRITE-WINS, and about sixty route bodies (plus
57
+ * `apps/web/lib/api/tenant.ts`) call `setActor(grant.userId, …)` themselves
58
+ * instead of going through the impersonation-aware stamp in
59
+ * `apps/web/lib/rbac/guards.ts`. While a session is impersonated the tenant
60
+ * guard resolves that grant for the EFFECTIVE subject, so those calls
61
+ * re-stamp `userId` with the person being impersonated — and an audit row
62
+ * derived from it then reads as though the impersonated person did the thing
63
+ * themselves. That is precisely the mis-attribution the epic calls
64
+ * unrecoverable, and `audit_logs` is append-only, so nothing can put it
65
+ * right afterwards.
66
+ *
67
+ * Editing those sixty call sites would fix today's tree and rot the moment
68
+ * someone writes the sixty-first, so the invariant is enforced HERE instead:
69
+ * the real human is recorded ONCE, by the stamp that knows both halves, and
70
+ * an unaware `setActor(someId)` has no way to reach it — it moves only when
71
+ * the impersonation itself is re-declared or cleared. The audit writer
72
+ * (`apps/web/lib/audit/audit.ts`) prefers it over `userId` whenever a live
73
+ * impersonation is present, which is what makes a plain re-stamp harmless.
74
+ *
75
+ * `userId` is left alone on purpose: it also feeds `created_by`/`updated_by`
76
+ * via the audit extension, where "the id this request is acting under" is a
77
+ * different (and mutable, therefore correctable) question from "who is
78
+ * answerable for this append-only row".
79
+ */
80
+ realUserId?: string | null;
81
+ }
82
+ export interface ActorContext extends ActorAttributionSnapshot {
83
+ /** The acting admin's DB `users.id`, stamped onto created_by/updated_by. */
84
+ userId: string;
85
+ }
86
+ /** Run `fn` with `userId` as the current actor. Nested calls override. */
87
+ export declare const runWithActor: <T>(userId: string, fn: () => T, attribution?: ActorAttribution) => T;
88
+ /**
89
+ * Establish an EMPTY actor scope for one request and run `fn` inside it — the
90
+ * request-boundary bootstrap (`createRouteHandler` wraps every handler in it).
91
+ *
92
+ * Why it must exist: {@link setActor} inside an AWAITED guard uses `enterWith`,
93
+ * which only applies to the guard's own async continuation — the CALLER resumes
94
+ * with the context it captured before the call, so the stamp silently vanishes
95
+ * and every audit entry reads "system". With a scope established here,
96
+ * `setActor` MUTATES the shared context object instead, which every frame of
97
+ * the request's async tree observes — stamps from arbitrarily deep guards
98
+ * survive back into the handler and its repositories.
99
+ */
100
+ export declare const runWithActorScope: <T>(fn: () => T) => T;
101
+ /**
102
+ * Stamp the current actor for the rest of this request. A falsy id (e.g. the
103
+ * superadmin env-grant carries no DB user id) is ignored so nothing is ever
104
+ * stamped with an empty string. Attribution fields MERGE — only the fields
105
+ * passed are updated — so a guard that knows just the scope doesn't erase a
106
+ * role a caller stamped (or vice versa). Inside a {@link runWithActorScope}
107
+ * boundary the stamp mutates the shared context (survives caller awaits);
108
+ * without one it falls back to `enterWith` (same-context callers only).
109
+ */
110
+ export declare const setActor: (userId: string, attribution?: ActorAttribution) => void;
111
+ /** The current actor's `users.id`, or undefined when no actor is set. */
112
+ export declare const getActorUserId: () => string | undefined;
113
+ /**
114
+ * The current actor's role/scope attribution (FUT-152) plus the impersonation
115
+ * PAIR (FUT-458) — the subject the request is rendered as, and the real human
116
+ * behind it — if stamped. Every field is `undefined` when nothing stamped it:
117
+ * the audit writer normalizes that to NULL at the row, so the distinction
118
+ * between "never stamped" and "explicitly cleared" stays here, where
119
+ * {@link setActor}'s merge rule needs it, and never leaks into a column.
120
+ *
121
+ * Both halves of the pair are returned together, and consumers must read them
122
+ * together: `onBehalfOfUserId` alone says an impersonation was *declared*,
123
+ * `realUserId` says who is answerable for it. A consumer that sees one without
124
+ * the other is looking at a context nothing in production can produce, and
125
+ * should treat the session as NOT impersonated rather than guess.
126
+ */
127
+ export declare const getActorAttribution: () => ActorAttributionSnapshot;
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ /**
3
+ * Per-request "who is acting" context (FUT-168), backed by Node's
4
+ * AsyncLocalStorage. The auth layer sets the current admin's `users.id` once a
5
+ * request is authorized; the Prisma audit extension (see `audit-extension.ts`)
6
+ * reads it to auto-stamp `created_by` / `updated_by` on tracked models — so no
7
+ * repository signature or call site has to thread the actor through by hand.
8
+ *
9
+ * FUT-152 enriches the context with the ROLE and SCOPE the request was
10
+ * authorized under, so audit entries can record not just who acted but under
11
+ * which authority — populated by the same guards that stamp the user id.
12
+ *
13
+ * FUT-458 adds a SECOND identity: the subject a request is being rendered as
14
+ * while a super-admin impersonation or a "Ver como" preview is live. It is
15
+ * stored as a PAIR — the subject plus the real human behind it — and the
16
+ * second half of that pair is written by this module alone. See
17
+ * {@link ActorAttributionSnapshot.realUserId} for why the real human cannot
18
+ * simply be read back out of {@link ActorContext.userId}.
19
+ *
20
+ * Server-only: AsyncLocalStorage is a Node API. Never import from the Edge
21
+ * middleware runtime.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.getActorAttribution = exports.getActorUserId = exports.setActor = exports.runWithActorScope = exports.runWithActor = void 0;
25
+ const node_async_hooks_1 = require("node:async_hooks");
26
+ // Kept on globalThis so Next dev / Turbopack hot-reload (which re-evaluates this
27
+ // module) can't create a second store whose context is invisible to closures
28
+ // captured against the first.
29
+ const globalStore = globalThis;
30
+ const store = () => (globalStore.__futurePayActorStore ??= new node_async_hooks_1.AsyncLocalStorage());
31
+ /**
32
+ * The REAL human behind `onBehalfOfUserId`, derived (never accepted) from the
33
+ * stamp that declares the impersonation (FUT-458).
34
+ *
35
+ * `userId` is that human by construction: the only stamp in the codebase that
36
+ * passes a non-null `onBehalfOfUserId` is `stampActor` in
37
+ * `apps/web/lib/rbac/guards.ts`, which hands over the real id and the subject
38
+ * in the SAME call. That co-location is the whole reason the pair can be
39
+ * trusted — nothing else knows both halves at once, so nothing else can forge
40
+ * one.
41
+ *
42
+ * Clearing is symmetric: ending an impersonation drops BOTH halves. A stale
43
+ * real id left behind would make every later write in the request look as
44
+ * though it still carried a hidden second identity.
45
+ */
46
+ const realActorFor = (userId, onBehalfOfUserId) => onBehalfOfUserId === null ? null : userId;
47
+ /**
48
+ * A fresh context for `userId`. The impersonation pair is derived only when
49
+ * the stamp expressed an opinion — `undefined` means "no opinion" everywhere
50
+ * in this module, and must not be written as a value.
51
+ */
52
+ const freshContext = (userId, attribution) => ({
53
+ userId,
54
+ ...attribution,
55
+ ...(attribution.onBehalfOfUserId !== undefined
56
+ ? { realUserId: realActorFor(userId, attribution.onBehalfOfUserId) }
57
+ : {}),
58
+ });
59
+ /** Run `fn` with `userId` as the current actor. Nested calls override. */
60
+ const runWithActor = (userId, fn, attribution = {}) => store().run(freshContext(userId, attribution), fn);
61
+ exports.runWithActor = runWithActor;
62
+ /**
63
+ * Establish an EMPTY actor scope for one request and run `fn` inside it — the
64
+ * request-boundary bootstrap (`createRouteHandler` wraps every handler in it).
65
+ *
66
+ * Why it must exist: {@link setActor} inside an AWAITED guard uses `enterWith`,
67
+ * which only applies to the guard's own async continuation — the CALLER resumes
68
+ * with the context it captured before the call, so the stamp silently vanishes
69
+ * and every audit entry reads "system". With a scope established here,
70
+ * `setActor` MUTATES the shared context object instead, which every frame of
71
+ * the request's async tree observes — stamps from arbitrarily deep guards
72
+ * survive back into the handler and its repositories.
73
+ */
74
+ const runWithActorScope = (fn) => store().run({ userId: "" }, fn);
75
+ exports.runWithActorScope = runWithActorScope;
76
+ /**
77
+ * Stamp the current actor for the rest of this request. A falsy id (e.g. the
78
+ * superadmin env-grant carries no DB user id) is ignored so nothing is ever
79
+ * stamped with an empty string. Attribution fields MERGE — only the fields
80
+ * passed are updated — so a guard that knows just the scope doesn't erase a
81
+ * role a caller stamped (or vice versa). Inside a {@link runWithActorScope}
82
+ * boundary the stamp mutates the shared context (survives caller awaits);
83
+ * without one it falls back to `enterWith` (same-context callers only).
84
+ */
85
+ const setActor = (userId, attribution = {}) => {
86
+ if (!userId)
87
+ return;
88
+ const current = store().getStore();
89
+ if (current) {
90
+ current.userId = userId;
91
+ if (attribution.role !== undefined)
92
+ current.role = attribution.role;
93
+ if (attribution.scope !== undefined)
94
+ current.scope = attribution.scope;
95
+ // FUT-458 — same merge rule, and the reason it has to be `!== undefined`
96
+ // rather than a truthiness check: ENDING an impersonation is expressed as
97
+ // `null`, and a truthy guard would treat that clear as "no opinion" and
98
+ // leave the previous target standing for the rest of the request.
99
+ //
100
+ // Note what this branch does NOT do: an unaware stamp — one that passes no
101
+ // `onBehalfOfUserId` at all — moves `userId` and nothing else. The
102
+ // impersonation pair survives it untouched, which is the property the
103
+ // audit trail is built on (see `ActorAttributionSnapshot.realUserId`).
104
+ if (attribution.onBehalfOfUserId !== undefined) {
105
+ current.onBehalfOfUserId = attribution.onBehalfOfUserId;
106
+ current.realUserId = realActorFor(userId, attribution.onBehalfOfUserId);
107
+ }
108
+ return;
109
+ }
110
+ store().enterWith(freshContext(userId, attribution));
111
+ };
112
+ exports.setActor = setActor;
113
+ /** The current actor's `users.id`, or undefined when no actor is set. */
114
+ const getActorUserId = () => store().getStore()?.userId || undefined;
115
+ exports.getActorUserId = getActorUserId;
116
+ /**
117
+ * The current actor's role/scope attribution (FUT-152) plus the impersonation
118
+ * PAIR (FUT-458) — the subject the request is rendered as, and the real human
119
+ * behind it — if stamped. Every field is `undefined` when nothing stamped it:
120
+ * the audit writer normalizes that to NULL at the row, so the distinction
121
+ * between "never stamped" and "explicitly cleared" stays here, where
122
+ * {@link setActor}'s merge rule needs it, and never leaks into a column.
123
+ *
124
+ * Both halves of the pair are returned together, and consumers must read them
125
+ * together: `onBehalfOfUserId` alone says an impersonation was *declared*,
126
+ * `realUserId` says who is answerable for it. A consumer that sees one without
127
+ * the other is looking at a context nothing in production can produce, and
128
+ * should treat the session as NOT impersonated rather than guess.
129
+ */
130
+ const getActorAttribution = () => {
131
+ const context = store().getStore();
132
+ return {
133
+ role: context?.role,
134
+ scope: context?.scope,
135
+ onBehalfOfUserId: context?.onBehalfOfUserId,
136
+ realUserId: context?.realUserId,
137
+ };
138
+ };
139
+ exports.getActorAttribution = getActorAttribution;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Prisma client extension that makes the audit log physically append-only at
3
+ * the client layer (FUT-209).
4
+ *
5
+ * The audit trail's value rests on immutability: an entry that can be edited
6
+ * or deleted after the fact proves nothing. There is no code path that should
7
+ * ever mutate `audit_logs`, so every update/upsert/delete delegate on the
8
+ * model throws — including the batch variants — before reaching the database.
9
+ * The house pattern for append-only tables (entity_versions) relies on code
10
+ * discipline alone; the audit log gets the harder guard because it exists
11
+ * precisely for the cases where discipline failed.
12
+ *
13
+ * The ONLY sanctioned removal is the retention sweep (12-month policy), which
14
+ * runs raw SQL over `created_at` — `$executeRaw` bypasses model delegates and
15
+ * is untouched by this extension. Test truncation uses raw SQL for the same
16
+ * reason.
17
+ */
18
+ import type { PrismaClient } from '@prisma/client';
19
+ /** Thrown when code attempts to mutate an append-only model. */
20
+ export declare class AppendOnlyViolationError extends Error {
21
+ constructor(model: string, operation: string);
22
+ }
23
+ /**
24
+ * Wrap a client so mutating operations on append-only models throw.
25
+ * Returns the client typed as {@link PrismaClient}: the extension only adds
26
+ * query middleware (no new delegates), so every existing call site stays valid.
27
+ */
28
+ export declare function applyAppendOnlyGuard(client: PrismaClient): PrismaClient;
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ /**
3
+ * Prisma client extension that makes the audit log physically append-only at
4
+ * the client layer (FUT-209).
5
+ *
6
+ * The audit trail's value rests on immutability: an entry that can be edited
7
+ * or deleted after the fact proves nothing. There is no code path that should
8
+ * ever mutate `audit_logs`, so every update/upsert/delete delegate on the
9
+ * model throws — including the batch variants — before reaching the database.
10
+ * The house pattern for append-only tables (entity_versions) relies on code
11
+ * discipline alone; the audit log gets the harder guard because it exists
12
+ * precisely for the cases where discipline failed.
13
+ *
14
+ * The ONLY sanctioned removal is the retention sweep (12-month policy), which
15
+ * runs raw SQL over `created_at` — `$executeRaw` bypasses model delegates and
16
+ * is untouched by this extension. Test truncation uses raw SQL for the same
17
+ * reason.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.AppendOnlyViolationError = void 0;
21
+ exports.applyAppendOnlyGuard = applyAppendOnlyGuard;
22
+ /** Prisma model names that must never be updated or deleted via the model API. */
23
+ const APPEND_ONLY_MODELS = new Set(['AuditLog']);
24
+ /** Thrown when code attempts to mutate an append-only model. */
25
+ class AppendOnlyViolationError extends Error {
26
+ constructor(model, operation) {
27
+ super(`${model} is append-only: "${operation}" is not allowed. ` +
28
+ 'Audit entries are immutable; retention sweeps use raw SQL.');
29
+ this.name = 'AppendOnlyViolationError';
30
+ }
31
+ }
32
+ exports.AppendOnlyViolationError = AppendOnlyViolationError;
33
+ /** Throw for an append-only model, else pass the call through untouched. */
34
+ function guard(model, operation, args, query) {
35
+ if (APPEND_ONLY_MODELS.has(model)) {
36
+ throw new AppendOnlyViolationError(model, operation);
37
+ }
38
+ return query(args);
39
+ }
40
+ /**
41
+ * Wrap a client so mutating operations on append-only models throw.
42
+ * Returns the client typed as {@link PrismaClient}: the extension only adds
43
+ * query middleware (no new delegates), so every existing call site stays valid.
44
+ */
45
+ function applyAppendOnlyGuard(client) {
46
+ const extended = client.$extends({
47
+ name: 'appendOnlyGuard',
48
+ query: {
49
+ $allModels: {
50
+ update({ model, args, query }) {
51
+ return guard(model, 'update', args, query);
52
+ },
53
+ updateMany({ model, args, query }) {
54
+ return guard(model, 'updateMany', args, query);
55
+ },
56
+ updateManyAndReturn({ model, args, query }) {
57
+ return guard(model, 'updateManyAndReturn', args, query);
58
+ },
59
+ upsert({ model, args, query }) {
60
+ return guard(model, 'upsert', args, query);
61
+ },
62
+ delete({ model, args, query }) {
63
+ return guard(model, 'delete', args, query);
64
+ },
65
+ deleteMany({ model, args, query }) {
66
+ return guard(model, 'deleteMany', args, query);
67
+ },
68
+ },
69
+ },
70
+ });
71
+ return extended;
72
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Prisma client extension that auto-stamps change attribution (FUT-168).
3
+ *
4
+ * For the tracked models it fills `createdBy` on create and `updatedBy` on
5
+ * create/update from the current actor (see `actor-context.ts`), so repositories
6
+ * and actions never pass an actor id explicitly. A write with no actor in scope
7
+ * (system/seed/unauthenticated) is left untouched — the columns stay NULL.
8
+ *
9
+ * Only fields the caller did not already set are filled, so an explicit override
10
+ * always wins.
11
+ */
12
+ import type { PrismaClient } from '@prisma/client';
13
+ /**
14
+ * Wrap a client so tracked-model writes are attributed to the current actor.
15
+ * Returns the client typed as {@link PrismaClient}: the extension only adds query
16
+ * middleware (no new delegates), so every existing call site stays valid.
17
+ */
18
+ export declare function applyAuditStamps(client: PrismaClient): PrismaClient;
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+ /**
3
+ * Prisma client extension that auto-stamps change attribution (FUT-168).
4
+ *
5
+ * For the tracked models it fills `createdBy` on create and `updatedBy` on
6
+ * create/update from the current actor (see `actor-context.ts`), so repositories
7
+ * and actions never pass an actor id explicitly. A write with no actor in scope
8
+ * (system/seed/unauthenticated) is left untouched — the columns stay NULL.
9
+ *
10
+ * Only fields the caller did not already set are filled, so an explicit override
11
+ * always wins.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.applyAuditStamps = applyAuditStamps;
15
+ const actor_context_1 = require("./actor-context");
16
+ const search_normalize_1 = require("./search-normalize");
17
+ /** Prisma model names that carry `created_by`/`updated_by` + `search_name`. */
18
+ const TRACKED_MODELS = new Set([
19
+ 'MenuItem',
20
+ 'InventoryItem',
21
+ 'ProductCategory',
22
+ 'Supplier',
23
+ 'Discount',
24
+ ]);
25
+ /** Fill created_by + updated_by on a create payload (without clobbering overrides). */
26
+ function stampCreate(data, userId) {
27
+ if (data.createdBy === undefined)
28
+ data.createdBy = userId;
29
+ if (data.updatedBy === undefined)
30
+ data.updatedBy = userId;
31
+ }
32
+ /** Fill updated_by on an update payload. */
33
+ function stampUpdate(data, userId) {
34
+ if (data.updatedBy === undefined)
35
+ data.updatedBy = userId;
36
+ }
37
+ /**
38
+ * Keep `search_name` in sync with `name` for accent/case-insensitive search
39
+ * (FUT-168). Applied on every tracked write that sets `name` — including
40
+ * system/seed writes with no actor — so the column never drifts. A caller that
41
+ * sets `searchName` explicitly wins.
42
+ */
43
+ function stampSearchName(data) {
44
+ if (typeof data.name === 'string' && data.searchName === undefined) {
45
+ data.searchName = (0, search_normalize_1.normalizeSearchText)(data.name);
46
+ }
47
+ }
48
+ /**
49
+ * Wrap a client so tracked-model writes are attributed to the current actor.
50
+ * Returns the client typed as {@link PrismaClient}: the extension only adds query
51
+ * middleware (no new delegates), so every existing call site stays valid.
52
+ */
53
+ function applyAuditStamps(client) {
54
+ const extended = client.$extends({
55
+ name: 'auditStamps',
56
+ query: {
57
+ $allModels: {
58
+ create({ model, args, query }) {
59
+ if (TRACKED_MODELS.has(model) && args.data) {
60
+ const data = args.data;
61
+ stampSearchName(data);
62
+ const userId = (0, actor_context_1.getActorUserId)();
63
+ if (userId)
64
+ stampCreate(data, userId);
65
+ }
66
+ return query(args);
67
+ },
68
+ createMany({ model, args, query }) {
69
+ if (TRACKED_MODELS.has(model) && args.data) {
70
+ const userId = (0, actor_context_1.getActorUserId)();
71
+ const rows = Array.isArray(args.data) ? args.data : [args.data];
72
+ rows.forEach((row) => {
73
+ const data = row;
74
+ stampSearchName(data);
75
+ if (userId)
76
+ stampCreate(data, userId);
77
+ });
78
+ }
79
+ return query(args);
80
+ },
81
+ update({ model, args, query }) {
82
+ if (TRACKED_MODELS.has(model) && args.data) {
83
+ const data = args.data;
84
+ stampSearchName(data);
85
+ const userId = (0, actor_context_1.getActorUserId)();
86
+ if (userId)
87
+ stampUpdate(data, userId);
88
+ }
89
+ return query(args);
90
+ },
91
+ updateMany({ model, args, query }) {
92
+ if (TRACKED_MODELS.has(model) && args.data) {
93
+ const data = args.data;
94
+ stampSearchName(data);
95
+ const userId = (0, actor_context_1.getActorUserId)();
96
+ if (userId)
97
+ stampUpdate(data, userId);
98
+ }
99
+ return query(args);
100
+ },
101
+ upsert({ model, args, query }) {
102
+ if (TRACKED_MODELS.has(model)) {
103
+ const userId = (0, actor_context_1.getActorUserId)();
104
+ if (args.create) {
105
+ const data = args.create;
106
+ stampSearchName(data);
107
+ if (userId)
108
+ stampCreate(data, userId);
109
+ }
110
+ if (args.update) {
111
+ const data = args.update;
112
+ stampSearchName(data);
113
+ if (userId)
114
+ stampUpdate(data, userId);
115
+ }
116
+ }
117
+ return query(args);
118
+ },
119
+ },
120
+ },
121
+ });
122
+ return extended;
123
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Prisma client singleton.
3
+ *
4
+ * Default mode builds a PostgreSQL-backed `PrismaClient` (the real / production
5
+ * database, driven by `DATABASE_URL`). When a PGlite flag is present in the
6
+ * environment the same client is built on the community `pglite-prisma-adapter`
7
+ * so PGlite (WASM Postgres) becomes the app runtime database for local dev and
8
+ * Playwright e2e — no Docker Postgres required.
9
+ *
10
+ * PGlite mode is selected by ANY of:
11
+ * - `USE_FILE_DB=1` (or `true`) → in-memory, unless a dir is also set
12
+ * - `PGLITE_DATA_DIR=<dir>` → file-backed at <dir>
13
+ * - `DATABASE_URL` starting with `pglite:` → `pglite:memory` or `pglite:<dir>`
14
+ *
15
+ * Production safety: when `NODE_ENV=production` the database is ALWAYS real
16
+ * PostgreSQL unless `USE_FILE_DB=1` is set explicitly — so a stray
17
+ * `PGLITE_DATA_DIR` / `pglite:` URL can never silently point prod at a throwaway
18
+ * WASM DB. `USE_FILE_DB=0` forces real PostgreSQL everywhere (dev/e2e opt-out).
19
+ *
20
+ * The generated `PrismaClient` type is re-exported below so consumers stay fully
21
+ * typed without any hand-written stub interfaces. Server-only: never import this
22
+ * (directly or transitively) from middleware or an Edge-runtime route — PGlite
23
+ * needs Node's filesystem/WASM and Prisma is not Edge-safe.
24
+ */
25
+ export type { PrismaClient } from '@prisma/client';
26
+ import type { PrismaClient } from '@prisma/client';
27
+ export { AppendOnlyViolationError } from './append-only-extension';
28
+ export { getActorAttribution, getActorUserId, runWithActor, runWithActorScope, setActor, type ActorAttribution, type ActorAttributionSnapshot, type ActorContext, } from './actor-context';
29
+ export { normalizeSearchText } from './search-normalize';
30
+ /**
31
+ * Get or create the Prisma client instance.
32
+ *
33
+ * Lazy and memoised: concurrent callers share a single in-flight init promise,
34
+ * so only one client (and at most one PGlite instance) is ever created. A
35
+ * missing generated client surfaces an actionable error rather than a hard
36
+ * module-load failure.
37
+ */
38
+ export declare const getPrismaClient: () => Promise<PrismaClient>;
39
+ /**
40
+ * Set a custom Prisma client instance (for testing).
41
+ */
42
+ export declare const setPrismaClient: (client: PrismaClient) => void;
43
+ /**
44
+ * Reset the Prisma client instance (for testing).
45
+ */
46
+ export declare const resetPrismaClient: () => void;