@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
@@ -0,0 +1,75 @@
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
+
19
+ import type { PrismaClient } from '@prisma/client';
20
+
21
+ /** Prisma model names that must never be updated or deleted via the model API. */
22
+ const APPEND_ONLY_MODELS = new Set(['AuditLog']);
23
+
24
+ /** Thrown when code attempts to mutate an append-only model. */
25
+ export class AppendOnlyViolationError extends Error {
26
+ constructor(model: string, operation: string) {
27
+ super(
28
+ `${model} is append-only: "${operation}" is not allowed. ` +
29
+ 'Audit entries are immutable; retention sweeps use raw SQL.',
30
+ );
31
+ this.name = 'AppendOnlyViolationError';
32
+ }
33
+ }
34
+
35
+ /** Throw for an append-only model, else pass the call through untouched. */
36
+ function guard<A, R>(model: string, operation: string, args: A, query: (args: A) => R): R {
37
+ if (APPEND_ONLY_MODELS.has(model)) {
38
+ throw new AppendOnlyViolationError(model, operation);
39
+ }
40
+ return query(args);
41
+ }
42
+
43
+ /**
44
+ * Wrap a client so mutating operations on append-only models throw.
45
+ * Returns the client typed as {@link PrismaClient}: the extension only adds
46
+ * query middleware (no new delegates), so every existing call site stays valid.
47
+ */
48
+ export function applyAppendOnlyGuard(client: PrismaClient): PrismaClient {
49
+ const extended = client.$extends({
50
+ name: 'appendOnlyGuard',
51
+ query: {
52
+ $allModels: {
53
+ update({ model, args, query }) {
54
+ return guard(model, 'update', args, query);
55
+ },
56
+ updateMany({ model, args, query }) {
57
+ return guard(model, 'updateMany', args, query);
58
+ },
59
+ updateManyAndReturn({ model, args, query }) {
60
+ return guard(model, 'updateManyAndReturn', args, query);
61
+ },
62
+ upsert({ model, args, query }) {
63
+ return guard(model, 'upsert', args, query);
64
+ },
65
+ delete({ model, args, query }) {
66
+ return guard(model, 'delete', args, query);
67
+ },
68
+ deleteMany({ model, args, query }) {
69
+ return guard(model, 'deleteMany', args, query);
70
+ },
71
+ },
72
+ },
73
+ });
74
+ return extended as unknown as PrismaClient;
75
+ }
@@ -0,0 +1,121 @@
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
+
13
+ import type { PrismaClient } from '@prisma/client';
14
+
15
+ import { getActorUserId } from './actor-context';
16
+ import { normalizeSearchText } from './search-normalize';
17
+
18
+ /** Prisma model names that carry `created_by`/`updated_by` + `search_name`. */
19
+ const TRACKED_MODELS = new Set([
20
+ 'MenuItem',
21
+ 'InventoryItem',
22
+ 'ProductCategory',
23
+ 'Supplier',
24
+ 'Discount',
25
+ ]);
26
+
27
+ type MutableData = Record<string, unknown>;
28
+
29
+ /** Fill created_by + updated_by on a create payload (without clobbering overrides). */
30
+ function stampCreate(data: MutableData, userId: string): void {
31
+ if (data.createdBy === undefined) data.createdBy = userId;
32
+ if (data.updatedBy === undefined) data.updatedBy = userId;
33
+ }
34
+
35
+ /** Fill updated_by on an update payload. */
36
+ function stampUpdate(data: MutableData, userId: string): void {
37
+ if (data.updatedBy === undefined) data.updatedBy = userId;
38
+ }
39
+
40
+ /**
41
+ * Keep `search_name` in sync with `name` for accent/case-insensitive search
42
+ * (FUT-168). Applied on every tracked write that sets `name` — including
43
+ * system/seed writes with no actor — so the column never drifts. A caller that
44
+ * sets `searchName` explicitly wins.
45
+ */
46
+ function stampSearchName(data: MutableData): void {
47
+ if (typeof data.name === 'string' && data.searchName === undefined) {
48
+ data.searchName = normalizeSearchText(data.name);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Wrap a client so tracked-model writes are attributed to the current actor.
54
+ * Returns the client typed as {@link PrismaClient}: the extension only adds query
55
+ * middleware (no new delegates), so every existing call site stays valid.
56
+ */
57
+ export function applyAuditStamps(client: PrismaClient): PrismaClient {
58
+ const extended = client.$extends({
59
+ name: 'auditStamps',
60
+ query: {
61
+ $allModels: {
62
+ create({ model, args, query }) {
63
+ if (TRACKED_MODELS.has(model) && args.data) {
64
+ const data = args.data as MutableData;
65
+ stampSearchName(data);
66
+ const userId = getActorUserId();
67
+ if (userId) stampCreate(data, userId);
68
+ }
69
+ return query(args);
70
+ },
71
+ createMany({ model, args, query }) {
72
+ if (TRACKED_MODELS.has(model) && args.data) {
73
+ const userId = getActorUserId();
74
+ const rows = Array.isArray(args.data) ? args.data : [args.data];
75
+ rows.forEach((row) => {
76
+ const data = row as MutableData;
77
+ stampSearchName(data);
78
+ if (userId) stampCreate(data, userId);
79
+ });
80
+ }
81
+ return query(args);
82
+ },
83
+ update({ model, args, query }) {
84
+ if (TRACKED_MODELS.has(model) && args.data) {
85
+ const data = args.data as MutableData;
86
+ stampSearchName(data);
87
+ const userId = getActorUserId();
88
+ if (userId) stampUpdate(data, userId);
89
+ }
90
+ return query(args);
91
+ },
92
+ updateMany({ model, args, query }) {
93
+ if (TRACKED_MODELS.has(model) && args.data) {
94
+ const data = args.data as MutableData;
95
+ stampSearchName(data);
96
+ const userId = getActorUserId();
97
+ if (userId) stampUpdate(data, userId);
98
+ }
99
+ return query(args);
100
+ },
101
+ upsert({ model, args, query }) {
102
+ if (TRACKED_MODELS.has(model)) {
103
+ const userId = getActorUserId();
104
+ if (args.create) {
105
+ const data = args.create as MutableData;
106
+ stampSearchName(data);
107
+ if (userId) stampCreate(data, userId);
108
+ }
109
+ if (args.update) {
110
+ const data = args.update as MutableData;
111
+ stampSearchName(data);
112
+ if (userId) stampUpdate(data, userId);
113
+ }
114
+ }
115
+ return query(args);
116
+ },
117
+ },
118
+ },
119
+ });
120
+ return extended as unknown as PrismaClient;
121
+ }
package/src/index.ts ADDED
@@ -0,0 +1,233 @@
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
+
26
+ // Re-export the generated client type.
27
+ export type { PrismaClient } from '@prisma/client';
28
+
29
+ import type { Prisma, PrismaClient } from '@prisma/client';
30
+
31
+ import { applyAppendOnlyGuard } from './append-only-extension';
32
+ import { applyAuditStamps } from './audit-extension';
33
+
34
+ // Append-only guard for the audit log (FUT-209): mutating the AuditLog model
35
+ // throws. Re-exported so tests can assert on the error type.
36
+ export { AppendOnlyViolationError } from './append-only-extension';
37
+
38
+ // Change-attribution context helpers (FUT-168): the auth layer calls `setActor`
39
+ // once a request is authorized; the audit extension applied below reads it to
40
+ // stamp created_by/updated_by. Re-exported here so consumers import them from
41
+ // the same `@12-apps/prisma` entry point as `getPrismaClient`.
42
+ export {
43
+ getActorAttribution,
44
+ getActorUserId,
45
+ runWithActor,
46
+ runWithActorScope,
47
+ setActor,
48
+ type ActorAttribution,
49
+ type ActorAttributionSnapshot,
50
+ type ActorContext,
51
+ } from './actor-context';
52
+ export { normalizeSearchText } from './search-normalize';
53
+
54
+ // The singleton and its in-flight init promise live on `globalThis` so that
55
+ // Next dev / Turbopack hot-reload (which re-evaluates this module) never spawns
56
+ // a second PGlite instance against the same dataDir — PGlite holds a single
57
+ // exclusive connection, and a duplicate would deadlock or corrupt the store.
58
+ const globalStore = globalThis as unknown as {
59
+ __futurePayPrisma?: PrismaClient;
60
+ __futurePayPrismaInit?: Promise<PrismaClient>;
61
+ };
62
+
63
+ /**
64
+ * Prisma log levels.
65
+ *
66
+ * Full `query` logging prints every SQL statement (table/column names) to the
67
+ * server console, so it is OFF by default — even in development — to avoid
68
+ * surfacing schema details and noise. Opt in locally with `PRISMA_LOG_QUERIES=1`.
69
+ * Production logs errors only.
70
+ */
71
+ const prismaLog = (): Array<'query' | 'error' | 'warn'> => {
72
+ if (process.env.NODE_ENV === 'production') return ['error'];
73
+ return process.env.PRISMA_LOG_QUERIES === '1'
74
+ ? ['query', 'error', 'warn']
75
+ : ['error', 'warn'];
76
+ };
77
+
78
+ /**
79
+ * Resolve PGlite mode from the environment. Returns `{ dataDir? }` when PGlite is
80
+ * requested (`dataDir` undefined ⇒ in-memory), or `null` for the default
81
+ * PostgreSQL path.
82
+ *
83
+ * Precedence (production can never fall back to a throwaway file DB by accident):
84
+ * 1. `USE_FILE_DB=0|false` → PostgreSQL (explicit opt-out always wins).
85
+ * 2. `NODE_ENV=production` → PostgreSQL, unless `USE_FILE_DB=1` is explicit.
86
+ * 3. `USE_FILE_DB=1|true` / `PGLITE_DATA_DIR` / a `pglite:` URL → PGlite.
87
+ * 4. otherwise → PostgreSQL.
88
+ */
89
+ /** The parsed PGlite-selection signals read from the environment. */
90
+ interface PgliteEnv {
91
+ url: string;
92
+ explicitDir: string | undefined;
93
+ forcedOn: boolean;
94
+ forcedOff: boolean;
95
+ urlIsPglite: boolean;
96
+ }
97
+
98
+ /**
99
+ * Whether the environment selects PGlite over PostgreSQL. Production is
100
+ * PostgreSQL unless a file DB is DELIBERATELY forced on; an explicit
101
+ * `USE_FILE_DB=0` always wins.
102
+ */
103
+ const isPgliteSelected = (env: PgliteEnv): boolean => {
104
+ if (env.forcedOff) return false;
105
+ if (process.env.NODE_ENV === 'production') return env.forcedOn;
106
+ return env.forcedOn || env.explicitDir !== undefined || env.urlIsPglite;
107
+ };
108
+
109
+ /**
110
+ * Resolve the PGlite target once selected: an explicit dir wins, else a
111
+ * `pglite:` URL (`pglite:memory` / `pglite://memory` ⇒ in-memory; `pglite:./dir`
112
+ * ⇒ file), else `USE_FILE_DB` with no directory ⇒ in-memory.
113
+ */
114
+ const pgliteTarget = (env: PgliteEnv): { dataDir?: string } => {
115
+ if (env.explicitDir) return { dataDir: env.explicitDir };
116
+ if (env.urlIsPglite) {
117
+ const raw = env.url.replace(/^pglite:(\/\/)?/, '');
118
+ return raw === '' || raw === 'memory' ? {} : { dataDir: raw };
119
+ }
120
+ return {};
121
+ };
122
+
123
+ const resolvePglite = (): { dataDir?: string } | null => {
124
+ const url = process.env.DATABASE_URL ?? '';
125
+ const flag = (process.env.USE_FILE_DB ?? '').toLowerCase();
126
+ const env: PgliteEnv = {
127
+ url,
128
+ explicitDir: process.env.PGLITE_DATA_DIR,
129
+ forcedOn: flag === '1' || flag === 'true',
130
+ forcedOff: flag === '0' || flag === 'false',
131
+ urlIsPglite: url.startsWith('pglite:'),
132
+ };
133
+
134
+ return isPgliteSelected(env) ? pgliteTarget(env) : null;
135
+ };
136
+
137
+ /** Build a PGlite-backed `PrismaClient` via the community driver adapter. */
138
+ const createPgliteClient = async (dataDir?: string): Promise<PrismaClient> => {
139
+ const { PGlite } = await import('@electric-sql/pglite');
140
+ // `pglite-prisma-adapter` is `exports`-only; type the dynamic import locally
141
+ // so it resolves under this package's classic ("Node") module resolution
142
+ // without a static type dependency on the package.
143
+ const { PrismaPGlite } = (await import('pglite-prisma-adapter')) as unknown as {
144
+ PrismaPGlite: new (client: unknown) => unknown;
145
+ };
146
+ const { PrismaClient: GeneratedPrismaClient } = await import('@prisma/client');
147
+
148
+ const client = dataDir ? new PGlite(dataDir) : new PGlite();
149
+ await client.waitReady;
150
+
151
+ const adapter = new PrismaPGlite(
152
+ client,
153
+ ) as Prisma.PrismaClientOptions['adapter'];
154
+ return applyAppendOnlyGuard(
155
+ applyAuditStamps(new GeneratedPrismaClient({ adapter, log: prismaLog() })),
156
+ );
157
+ };
158
+
159
+ /** Build the default PostgreSQL-backed `PrismaClient` (real / production DB). */
160
+ const createPostgresClient = async (): Promise<PrismaClient> => {
161
+ const { PrismaPg } = await import('@prisma/adapter-pg');
162
+ const { PrismaClient: GeneratedPrismaClient } = await import('@prisma/client');
163
+
164
+ const connectionString = process.env.DATABASE_URL;
165
+ if (!connectionString) {
166
+ throw new Error(
167
+ 'DATABASE_URL is required for the PostgreSQL Prisma client. Set it, or ' +
168
+ 'enable PGlite mode (USE_FILE_DB=1 / PGLITE_DATA_DIR / a "pglite:" URL).',
169
+ );
170
+ }
171
+
172
+ // Prisma 7 makes a driver adapter mandatory — PrismaClient no longer reads
173
+ // DATABASE_URL itself, and `new PrismaClient()` without an adapter throws at
174
+ // construction. PrismaPg takes a pg.PoolConfig and owns the pool internally.
175
+ const adapter = new PrismaPg({
176
+ connectionString,
177
+ }) as Prisma.PrismaClientOptions['adapter'];
178
+ return applyAppendOnlyGuard(
179
+ applyAuditStamps(new GeneratedPrismaClient({ adapter, log: prismaLog() })),
180
+ );
181
+ };
182
+
183
+ /**
184
+ * Get or create the Prisma client instance.
185
+ *
186
+ * Lazy and memoised: concurrent callers share a single in-flight init promise,
187
+ * so only one client (and at most one PGlite instance) is ever created. A
188
+ * missing generated client surfaces an actionable error rather than a hard
189
+ * module-load failure.
190
+ */
191
+ export const getPrismaClient = async (): Promise<PrismaClient> => {
192
+ if (globalStore.__futurePayPrisma) return globalStore.__futurePayPrisma;
193
+ if (globalStore.__futurePayPrismaInit) return globalStore.__futurePayPrismaInit;
194
+
195
+ const pglite = resolvePglite();
196
+ const init = (
197
+ pglite ? createPgliteClient(pglite.dataDir) : createPostgresClient()
198
+ )
199
+ .then((client) => {
200
+ globalStore.__futurePayPrisma = client;
201
+ globalStore.__futurePayPrismaInit = undefined;
202
+ return client;
203
+ })
204
+ .catch((error: unknown) => {
205
+ globalStore.__futurePayPrismaInit = undefined;
206
+ throw new Error(
207
+ `Prisma client not available (${pglite ? 'PGlite' : 'PostgreSQL'} mode). ` +
208
+ 'Run "pnpm --filter @12-apps/prisma prisma generate" from the ' +
209
+ 'monorepo root, or "pnpm prisma generate" from packages/prisma. ' +
210
+ `Cause: ${error instanceof Error ? error.message : String(error)}`,
211
+ { cause: error },
212
+ );
213
+ });
214
+
215
+ globalStore.__futurePayPrismaInit = init;
216
+ return init;
217
+ };
218
+
219
+ /**
220
+ * Set a custom Prisma client instance (for testing).
221
+ */
222
+ export const setPrismaClient = (client: PrismaClient): void => {
223
+ globalStore.__futurePayPrisma = client;
224
+ globalStore.__futurePayPrismaInit = undefined;
225
+ };
226
+
227
+ /**
228
+ * Reset the Prisma client instance (for testing).
229
+ */
230
+ export const resetPrismaClient = (): void => {
231
+ globalStore.__futurePayPrisma = undefined;
232
+ globalStore.__futurePayPrismaInit = undefined;
233
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Accent- and case-insensitive search normalization (FUT-168). Used to keep a
3
+ * denormalized `search_name` column on searchable models (maintained by the
4
+ * audit extension) and to normalize the query at read time, so "sabao" matches
5
+ * "Sabão". DB-agnostic: plain lowercased, diacritic-stripped text works
6
+ * identically on PostgreSQL and PGlite (no `unaccent` extension needed).
7
+ */
8
+
9
+ /** Unicode combining diacritical marks (U+0300–U+036F), split out by NFD. */
10
+ const COMBINING_MARKS = /[̀-ͯ]/g;
11
+
12
+ /**
13
+ * Strip diacritics and lowercase. NFD splits accented letters into base +
14
+ * combining mark, then the marks are removed. Using the explicit code-point
15
+ * range (not `\p{Diacritic}`) keeps it valid under the package's compile target.
16
+ */
17
+ export function normalizeSearchText(value: string): string {
18
+ return value.normalize('NFD').replace(COMBINING_MARKS, '').toLowerCase();
19
+ }