@12-apps/prisma 1.4.0 → 1.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/prisma",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Prisma host: the multi-file schema folder, the plugin migration seam, and the shared PrismaClient singleton with its audit / append-only extensions",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  }
13
13
  },
14
14
  "scripts": {
15
- "build": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate && tsc",
15
+ "build": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate && tsc",
16
16
  "clean": "rm -rf dist node_modules coverage",
17
17
  "test": "node ../../scripts/vitest-with-teardown.mjs run",
18
18
  "test:watch": "vitest watch",
@@ -20,7 +20,7 @@
20
20
  "lint": "pnpm run lint:files .",
21
21
  "lint:fix": "bash -c 'eslint \"${@:-.}\" --max-warnings 0 --fix' _",
22
22
  "typecheck": "tsc --noEmit",
23
- "prisma:generate": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate",
23
+ "prisma:generate": "node scripts/sync-lifecycle-schema.mjs && node scripts/sync-research-schema.mjs && node scripts/sync-shift-schema.mjs --check && node scripts/sync-jobs-schema.mjs --check && node scripts/sync-entitlements-schema.mjs --check && node scripts/sync-payments-schema.mjs --check && node scripts/sync-report-builder-schema.mjs --check && node scripts/sync-rbac-schema.mjs --check && node scripts/sync-onboarding-schema.mjs --check && node scripts/sync-mcp-schema.mjs --check && node scripts/sync-audit-schema.mjs --check && node scripts/sync-prisma-plugins.mjs --check && prisma generate",
24
24
  "prisma:migrate": "prisma migrate dev",
25
25
  "prisma:push": "prisma db push",
26
26
  "prisma:studio": "prisma studio",
@@ -43,7 +43,9 @@
43
43
  "prisma:sync-onboarding": "node scripts/sync-onboarding-schema.mjs",
44
44
  "prisma:sync-onboarding:check": "node scripts/sync-onboarding-schema.mjs --check",
45
45
  "prisma:sync-mcp": "node scripts/sync-mcp-schema.mjs",
46
- "prisma:sync-mcp:check": "node scripts/sync-mcp-schema.mjs --check"
46
+ "prisma:sync-mcp:check": "node scripts/sync-mcp-schema.mjs --check",
47
+ "prisma:sync-audit": "node scripts/sync-audit-schema.mjs",
48
+ "prisma:sync-audit:check": "node scripts/sync-audit-schema.mjs --check"
47
49
  },
48
50
  "dependencies": {
49
51
  "@electric-sql/pglite": "0.2.17",
@@ -53,6 +55,7 @@
53
55
  "pglite-prisma-adapter": "0.7.2"
54
56
  },
55
57
  "devDependencies": {
58
+ "@12-apps/audit": "^1.0.0",
56
59
  "@12-apps/entitlements": "^1.20.1",
57
60
  "@12-apps/entity-lifecycle": "^2.1.0",
58
61
  "@12-apps/eslint-config": "^1.20.0",
@@ -61,7 +64,7 @@
61
64
  "@12-apps/onboarding": "^1.20.0",
62
65
  "@12-apps/payments-backend": "^2.0.0",
63
66
  "@12-apps/product-research": "^2.0.0",
64
- "@12-apps/rbac": "^1.20.0",
67
+ "@12-apps/rbac": "^2.0.0",
65
68
  "@12-apps/report-builder": "^3.0.0",
66
69
  "@12-apps/shift": "^2.0.0",
67
70
  "@12-apps/typescript-config": "^1.20.0",
@@ -0,0 +1,87 @@
1
+ -- @12-apps/audit (12-14): the append-only action audit log, owned by the
2
+ -- package and copied into a host's migrations folder by its plugin-migration
3
+ -- sync. Runs identically on PostgreSQL and PGlite.
4
+ --
5
+ -- Append-only is enforced at the CLIENT layer (update/upsert/delete on the
6
+ -- model throw — see src/server/append-only-extension.ts), not by a DB trigger,
7
+ -- matching the house pattern. The only sanctioned removal is the retention
8
+ -- sweep, which goes through raw SQL over the `created_at` index below. Raw SQL
9
+ -- therefore BYPASSES the guard entirely — that is the documented blind spot,
10
+ -- and it is what makes the sweep possible at all.
11
+ --
12
+ -- ── REPLAY-SAFE ON PURPOSE ────────────────────────────────────────────────
13
+ -- Every statement here is idempotent (`IF NOT EXISTS`), because the first host
14
+ -- to adopt this package already HAS an `audit_logs` table created by its own,
15
+ -- earlier migration. A package migration is applied by name order, so this one
16
+ -- sorts AFTER the host's — and a bare `CREATE TABLE` would then fail
17
+ -- `prisma migrate deploy` on an existing database AND on a fresh one built from
18
+ -- the full folder. `prisma migrate resolve --applied` can only paper over the
19
+ -- first case, by hand, once per database; three PRs in this series have already
20
+ -- hit that wall (one deferred adoption entirely, one had to teach the plugin
21
+ -- sync a name-keyed carve-out). So the migration adopts an existing table
22
+ -- instead of demanding a baseline:
23
+ --
24
+ -- * a fresh host gets the table, the column and the five indexes;
25
+ -- * a host that already has the table gets only what it is missing;
26
+ -- * replaying the whole folder is a no-op.
27
+ --
28
+ -- What it deliberately does NOT do is reconcile a DIFFERENT shape. If a host's
29
+ -- pre-existing `audit_logs` is missing a column this package writes (other than
30
+ -- `on_behalf_of_user_id`, handled below) or types one differently, that is a
31
+ -- real divergence and belongs in the host's own migration — silently altering
32
+ -- columns under an append-only trail is not something a package should do.
33
+
34
+ CREATE TABLE IF NOT EXISTS "audit_logs" (
35
+ "id" TEXT NOT NULL,
36
+ "client_id" TEXT NOT NULL,
37
+ "actor_user_id" TEXT,
38
+ "actor_role" TEXT,
39
+ "scope" TEXT,
40
+ "on_behalf_of_user_id" TEXT,
41
+ "action" TEXT NOT NULL,
42
+ "resource_type" TEXT NOT NULL,
43
+ "resource_id" TEXT NOT NULL,
44
+ "before" JSONB NOT NULL DEFAULT '{}',
45
+ "after" JSONB NOT NULL DEFAULT '{}',
46
+ "request_id" TEXT,
47
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
48
+
49
+ CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
50
+ );
51
+
52
+ -- The impersonation half of the attribution pair, as a separate ADD COLUMN and
53
+ -- not only as a column of the CREATE above: a host that adopted an audit table
54
+ -- before impersonation existed has the table but not the column, and the
55
+ -- CREATE is a no-op for it. Nullable, no default, no backfill — NULL is the
56
+ -- honest and permanent value for every write made outside a session, and this
57
+ -- table is append-only, so the value is written at INSERT or never.
58
+ -- ADD COLUMN of a nullable column with no default is metadata-only in
59
+ -- PostgreSQL (no rewrite, no scan), so it is safe on a large trail.
60
+ ALTER TABLE "audit_logs" ADD COLUMN IF NOT EXISTS "on_behalf_of_user_id" TEXT;
61
+
62
+ -- The viewer's tenant-scoped filters: date (the default newest-first listing),
63
+ -- actor, resource.
64
+ CREATE INDEX IF NOT EXISTS "audit_logs_client_id_created_at_idx"
65
+ ON "audit_logs"("client_id", "created_at");
66
+ CREATE INDEX IF NOT EXISTS "audit_logs_client_id_actor_user_id_created_at_idx"
67
+ ON "audit_logs"("client_id", "actor_user_id", "created_at");
68
+ CREATE INDEX IF NOT EXISTS "audit_logs_client_id_resource_type_resource_id_idx"
69
+ ON "audit_logs"("client_id", "resource_type", "resource_id");
70
+ -- Action-pinned reads: the equality columns first, then the ORDER BY column.
71
+ CREATE INDEX IF NOT EXISTS "audit_logs_client_id_action_resource_type_created_at_idx"
72
+ ON "audit_logs"("client_id", "action", "resource_type", "created_at");
73
+ -- Serves the retention sweep.
74
+ CREATE INDEX IF NOT EXISTS "audit_logs_created_at_idx" ON "audit_logs"("created_at");
75
+
76
+ -- ⚠️ NOT CONCURRENTLY, and on a large existing trail that is a LOCK to plan
77
+ -- for rather than a footnote: `prisma migrate deploy` wraps each migration in a
78
+ -- transaction and `CREATE INDEX CONCURRENTLY` cannot run inside one, so each
79
+ -- build above takes a SHARE lock on `audit_logs` — and the writer inserts into
80
+ -- that table INSIDE every audited transaction, so those transactions block for
81
+ -- as long as the build takes.
82
+ --
83
+ -- RUNBOOK for a large deployment: build them out of band FIRST, against the
84
+ -- live database, before releasing — `CREATE INDEX CONCURRENTLY IF NOT EXISTS
85
+ -- <name> ON "audit_logs"(…)` with the names above — and every statement here
86
+ -- then finds its index present and is a no-op. On a fresh or small database,
87
+ -- just let the migration build them.
@@ -28,6 +28,7 @@
28
28
  "20260812120000_add_retention_watermarks",
29
29
  "20260812150000_add_mcp_oauth_tables",
30
30
  "20260812150000_add_onboarding_states",
31
+ "20260813120000_add_audit_log",
31
32
  "20260813120000_add_entity_lifecycle_tables"
32
33
  ]
33
34
  }
@@ -0,0 +1,68 @@
1
+ // @12-apps/audit (12-14) — the append-only action audit log, OWNED by the
2
+ // package. A host adopts it with `node scripts/sync-audit-schema.mjs`, which
3
+ // COPIES this file into the host's schema folder (never a symlink — Prisma
4
+ // lstats a migrations folder, so a symlinked migration is silently skipped,
5
+ // and `turbo prune` drops a partial whose owner is not a declared dependency).
6
+ //
7
+ // Deliberately NO foreign key into any host table (the payments/rbac doctrine):
8
+ // `client_id`, `actor_user_id` and `on_behalf_of_user_id` are by-value scalars,
9
+ // so the partial applies to a host whose tenant table is called anything at
10
+ // all. A host that wants referential integrity adds the constraint in its own
11
+ // migration — see ADOPTING.md.
12
+ //
13
+ // `action` and `resource_type` carry no CHECK constraint: the vocabulary is
14
+ // HOST CONFIG (`AuditVocabulary`), validated in TypeScript at the write site,
15
+ // so a newly instrumented feature adds an action without a migration.
16
+
17
+ model AuditLog {
18
+ id String @id @default(uuid())
19
+ clientId String @map("client_id")
20
+
21
+ /// The real human whose credentials authorized the write. NULL = a system
22
+ /// write (a provider webhook, a job). NEVER the identity an impersonated
23
+ /// session was rendering as — that is `onBehalfOfUserId`, below.
24
+ actorUserId String? @map("actor_user_id")
25
+ /// The role the request was AUTHORIZED under, and the scope the decision was
26
+ /// made in. Both describe the authorization the NAMED ACTOR used, so both are
27
+ /// NULL on a system write.
28
+ actorRole String? @map("actor_role")
29
+ scope String?
30
+
31
+ /// The identity the actor was ACTING AS (impersonation / "view as"), never
32
+ /// the actor themselves. Stored ALONGSIDE `actorUserId` rather than instead
33
+ /// of it: the trail has to answer "who really did this" and "who did the
34
+ /// screen claim to be" INDEPENDENTLY, and one column cannot. Conflating them
35
+ /// is unrecoverable after the fact — an entry that reads as though the
36
+ /// customer placed the order cannot later be told apart from one they really
37
+ /// placed, and this table is append-only, so nothing can correct it.
38
+ /// NULL for every ordinary write, which is why no backfill is possible.
39
+ onBehalfOfUserId String? @map("on_behalf_of_user_id")
40
+
41
+ action String
42
+ resourceType String @map("resource_type")
43
+ resourceId String @map("resource_id")
44
+
45
+ /// Allowlist-redacted state around the mutation: only the fields the
46
+ /// vocabulary names for this `resourceType` survive, and only flat JSON
47
+ /// scalars. `{}` for a pure create (no `before`).
48
+ before Json @default("{}")
49
+ after Json @default("{}")
50
+
51
+ requestId String? @map("request_id")
52
+ createdAt DateTime @default(now()) @map("created_at")
53
+
54
+ // The viewer's filters, always tenant-scoped: date (the default listing,
55
+ // newest first), actor, and resource.
56
+ @@index([clientId, createdAt])
57
+ @@index([clientId, actorUserId, createdAt])
58
+ @@index([clientId, resourceType, resourceId])
59
+ // Action-pinned reads (an `action_in` pill, a host's own reconciliation
60
+ // list): the three equality columns, then the sort column, so one index
61
+ // serves the page AND its count with no sort step. A rare action is the case
62
+ // that needs it — without the index the filtered scan reads the whole tenant
63
+ // partition to return an empty page, and pays it twice (findMany + count).
64
+ @@index([clientId, action, resourceType, createdAt])
65
+ // Serves the retention sweep ("entries older than N days").
66
+ @@index([createdAt])
67
+ @@map("audit_logs")
68
+ }