@12-apps/prisma 1.4.0 → 1.6.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 +13 -7
- package/prisma/migration-files.ts +12 -9
- package/prisma/migrations/20260813120000_add_audit_log/migration.sql +87 -0
- package/prisma/migrations/20260813180000_add_realtime_outbox/migration.sql +75 -0
- package/prisma/plugin-migrations.json +3 -1
- package/prisma/schema/audit.prisma +68 -0
- package/prisma/schema/realtime.prisma +75 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/prisma",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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-realtime-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-realtime-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,11 @@
|
|
|
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-realtime": "node scripts/sync-realtime-schema.mjs",
|
|
48
|
+
"prisma:sync-realtime:check": "node scripts/sync-realtime-schema.mjs --check",
|
|
49
|
+
"prisma:sync-audit": "node scripts/sync-audit-schema.mjs",
|
|
50
|
+
"prisma:sync-audit:check": "node scripts/sync-audit-schema.mjs --check"
|
|
47
51
|
},
|
|
48
52
|
"dependencies": {
|
|
49
53
|
"@electric-sql/pglite": "0.2.17",
|
|
@@ -53,16 +57,18 @@
|
|
|
53
57
|
"pglite-prisma-adapter": "0.7.2"
|
|
54
58
|
},
|
|
55
59
|
"devDependencies": {
|
|
60
|
+
"@12-apps/audit": "^1.0.0",
|
|
56
61
|
"@12-apps/entitlements": "^1.20.1",
|
|
57
62
|
"@12-apps/entity-lifecycle": "^2.1.0",
|
|
58
63
|
"@12-apps/eslint-config": "^1.20.0",
|
|
59
64
|
"@12-apps/jobs": "^2.0.0",
|
|
60
65
|
"@12-apps/mcp": "^1.20.0",
|
|
61
66
|
"@12-apps/onboarding": "^1.20.0",
|
|
62
|
-
"@12-apps/payments-backend": "^2.0.
|
|
67
|
+
"@12-apps/payments-backend": "^2.0.1",
|
|
63
68
|
"@12-apps/product-research": "^2.0.0",
|
|
64
|
-
"@12-apps/rbac": "^
|
|
65
|
-
"@12-apps/
|
|
69
|
+
"@12-apps/rbac": "^2.0.0",
|
|
70
|
+
"@12-apps/realtime": "^1.19.0",
|
|
71
|
+
"@12-apps/report-builder": "^3.0.1",
|
|
66
72
|
"@12-apps/shift": "^2.0.0",
|
|
67
73
|
"@12-apps/typescript-config": "^1.20.0",
|
|
68
74
|
"@types/node": "^22.10.6",
|
|
@@ -11,20 +11,23 @@ import { fileURLToPath } from 'node:url';
|
|
|
11
11
|
|
|
12
12
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
13
13
|
|
|
14
|
-
/** The folder holding every committed migration,
|
|
14
|
+
/** The folder holding every committed migration, host-owned and plugin alike. */
|
|
15
15
|
const MIGRATIONS_DIR = join(HERE, 'migrations');
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Every migration directory under `dir`, in timestamp order.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
20
|
+
* A package that OWNS part of the schema contributes its migrations here as
|
|
21
|
+
* committed COPIES (Prisma has no cross-package import), and `package.test.ts`
|
|
22
|
+
* gates that nothing under `prisma/` is a symlink.
|
|
23
|
+
*
|
|
24
|
+
* `statSync` rather than `Dirent.isDirectory()` even so, because the two differ
|
|
25
|
+
* exactly where it hurt: these migrations USED to arrive as symlinks, and
|
|
26
|
+
* `Dirent.isDirectory()` is FALSE for a symlink even when it resolves. That
|
|
27
|
+
* silently dropped them and left the package's tables missing from every
|
|
28
|
+
* PGlite-backed run — nothing failed loudly, because the schema was merely
|
|
29
|
+
* incomplete until something queried them. `statSync` follows a link, so should
|
|
30
|
+
* one ever reappear here this replay includes it rather than skipping it.
|
|
28
31
|
*/
|
|
29
32
|
export function discoverMigrations(dir: string = MIGRATIONS_DIR): string[] {
|
|
30
33
|
return readdirSync(dir)
|
|
@@ -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.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
-- @12-apps/realtime (12-16): the transactional OUTBOX table, owned by the
|
|
2
|
+
-- package and copied into a host's migrations folder by its plugin-migration
|
|
3
|
+
-- sync. Deliberately NO foreign keys and NO tenant column (the
|
|
4
|
+
-- payments-backend doctrine): a row names a TOPIC, and a topic already carries
|
|
5
|
+
-- whatever scope it has (`tenant:<id>:orders`), so there is nothing here for a
|
|
6
|
+
-- host to reconcile with its own tenancy model.
|
|
7
|
+
--
|
|
8
|
+
-- Runs identically on PostgreSQL + PGlite.
|
|
9
|
+
--
|
|
10
|
+
-- ---------------------------------------------------------------------------
|
|
11
|
+
-- EVERY STATEMENT IS GUARDED, AND THE GUARD IS PER COLUMN — NOT PER TABLE.
|
|
12
|
+
-- ---------------------------------------------------------------------------
|
|
13
|
+
-- `CREATE TABLE IF NOT EXISTS` alone is NOT replay-safe, and the way it fails
|
|
14
|
+
-- is silent. It skips the WHOLE table, so a host that already has
|
|
15
|
+
-- `realtime_outbox_events` from an earlier version of this package — or from
|
|
16
|
+
-- its own hand-rolled outbox — adopts this migration, sees it succeed, and
|
|
17
|
+
-- never gets the columns added since. `claimed_at` / `claimed_by` are exactly
|
|
18
|
+
-- that case: the claim protocol is what makes two concurrent drains safe, and
|
|
19
|
+
-- a host silently missing those columns would have every drain fail on an
|
|
20
|
+
-- unknown column, or (worse, if the code were laxer) publish every row twice.
|
|
21
|
+
--
|
|
22
|
+
-- This hazard was PROVEN on PGlite against a sibling package (review of #156:
|
|
23
|
+
-- a host stopped at an earlier migration adopted a guarded `CREATE TABLE` and
|
|
24
|
+
-- the later column never appeared, so the package's client 500'd on every
|
|
25
|
+
-- call). So each column is added with its own `ADD COLUMN IF NOT EXISTS`, and
|
|
26
|
+
-- each index with `IF NOT EXISTS`. The whole file is idempotent and can be
|
|
27
|
+
-- applied to a fresh database, to a database that already has it, and to one
|
|
28
|
+
-- that has an older shape of it.
|
|
29
|
+
--
|
|
30
|
+
-- The two NOT NULL columns with no permanent default get one temporarily and
|
|
31
|
+
-- then drop it: `ADD COLUMN … NOT NULL` fails outright on a table that already
|
|
32
|
+
-- holds rows, and that is precisely the host this guard is for.
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS "realtime_outbox_events" (
|
|
35
|
+
"id" TEXT NOT NULL,
|
|
36
|
+
|
|
37
|
+
CONSTRAINT "realtime_outbox_events_pkey" PRIMARY KEY ("id")
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
-- The event itself.
|
|
41
|
+
ALTER TABLE "realtime_outbox_events" ADD COLUMN IF NOT EXISTS "topic" TEXT NOT NULL DEFAULT '';
|
|
42
|
+
ALTER TABLE "realtime_outbox_events" ALTER COLUMN "topic" DROP DEFAULT;
|
|
43
|
+
ALTER TABLE "realtime_outbox_events" ADD COLUMN IF NOT EXISTS "type" TEXT NOT NULL DEFAULT '';
|
|
44
|
+
ALTER TABLE "realtime_outbox_events" ALTER COLUMN "type" DROP DEFAULT;
|
|
45
|
+
-- Identifiers only, never state — the payload rule the whole bus is built on.
|
|
46
|
+
ALTER TABLE "realtime_outbox_events"
|
|
47
|
+
ADD COLUMN IF NOT EXISTS "data" JSONB NOT NULL DEFAULT '{}';
|
|
48
|
+
|
|
49
|
+
ALTER TABLE "realtime_outbox_events"
|
|
50
|
+
ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
51
|
+
-- Null = still pending. Set once the event reached the bus.
|
|
52
|
+
ALTER TABLE "realtime_outbox_events" ADD COLUMN IF NOT EXISTS "published_at" TIMESTAMP(3);
|
|
53
|
+
|
|
54
|
+
-- The claim protocol. `claimed_at` null (or older than the drainer's lease) is
|
|
55
|
+
-- what makes a row takeable; the claim is a conditional UPDATE, so PostgreSQL
|
|
56
|
+
-- decides the winner and two drains racing one row cannot both publish it.
|
|
57
|
+
ALTER TABLE "realtime_outbox_events" ADD COLUMN IF NOT EXISTS "claimed_at" TIMESTAMP(3);
|
|
58
|
+
ALTER TABLE "realtime_outbox_events" ADD COLUMN IF NOT EXISTS "claimed_by" TEXT;
|
|
59
|
+
-- Counts CLAIMS, not failures: a drainer that crashes mid-publish records no
|
|
60
|
+
-- failure, so a failure counter would let a poison row retry forever.
|
|
61
|
+
ALTER TABLE "realtime_outbox_events"
|
|
62
|
+
ADD COLUMN IF NOT EXISTS "attempts" INTEGER NOT NULL DEFAULT 0;
|
|
63
|
+
ALTER TABLE "realtime_outbox_events" ADD COLUMN IF NOT EXISTS "last_error" TEXT;
|
|
64
|
+
|
|
65
|
+
-- The drain's own read (pending rows, oldest `created_at` first) and the purge's
|
|
66
|
+
-- (published rows past a cutoff): ONE composite serves both, because
|
|
67
|
+
-- `published_at` leads it. A separate index on `published_at` alone would be a
|
|
68
|
+
-- redundant prefix — no query it could serve is unserved here — and it would
|
|
69
|
+
-- cost a write amplification on every insert.
|
|
70
|
+
CREATE INDEX IF NOT EXISTS "realtime_outbox_events_published_at_created_at_idx"
|
|
71
|
+
ON "realtime_outbox_events"("published_at", "created_at");
|
|
72
|
+
-- Dropped rather than never created: an adopter that already applied an earlier
|
|
73
|
+
-- copy of this migration HAS the redundant index, and `IF EXISTS` makes the
|
|
74
|
+
-- removal replay-safe on a database that never did.
|
|
75
|
+
DROP INDEX IF EXISTS "realtime_outbox_events_published_at_idx";
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"20260812120000_add_retention_watermarks",
|
|
29
29
|
"20260812150000_add_mcp_oauth_tables",
|
|
30
30
|
"20260812150000_add_onboarding_states",
|
|
31
|
-
"
|
|
31
|
+
"20260813120000_add_audit_log",
|
|
32
|
+
"20260813120000_add_entity_lifecycle_tables",
|
|
33
|
+
"20260813180000_add_realtime_outbox"
|
|
32
34
|
]
|
|
33
35
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @12-apps/realtime — CANONICAL Prisma model partial (plug-and-play).
|
|
3
|
+
//
|
|
4
|
+
// This file is the single source of truth for the transactional OUTBOX table.
|
|
5
|
+
// A host project does NOT copy this model into its main schema by hand: it uses
|
|
6
|
+
// Prisma's multi-file schema folder and SYNCS this file into it (see
|
|
7
|
+
// scripts/sync-realtime-schema.mjs beside this folder, and
|
|
8
|
+
// packages/prisma/scripts/sync-realtime-schema.mjs in this repo for the host
|
|
9
|
+
// side — run before `prisma generate`). The migration ships alongside, in
|
|
10
|
+
// prisma/migrations/, and is COPIED into the host's migrations folder — never
|
|
11
|
+
// symlinked, because Prisma enumerates that folder with lstat and silently
|
|
12
|
+
// skips a linked directory.
|
|
13
|
+
//
|
|
14
|
+
// Host-agnostic by design: a row names a TOPIC, and a topic already carries
|
|
15
|
+
// whatever scope it has (`tenant:<id>:orders`, `user:<id>:notifications`). So
|
|
16
|
+
// there is no `client_id`, no relation to any host table, and nothing here for
|
|
17
|
+
// a host to reconcile with its own tenancy model.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/// One domain event awaiting publication onto the realtime bus.
|
|
21
|
+
///
|
|
22
|
+
/// Written INSIDE the same transaction as the domain write it describes, so the
|
|
23
|
+
/// event cannot exist without its cause nor the cause without its event, and
|
|
24
|
+
/// drained afterwards by a worker (`createRealtimeOutbox({ db }).drain()`).
|
|
25
|
+
/// That closes the window an inline `publishRealtimeEvent` leaves open: a
|
|
26
|
+
/// process that dies between the commit and the publish loses the event with no
|
|
27
|
+
/// trace at all.
|
|
28
|
+
///
|
|
29
|
+
/// The delivery guarantee is AT-LEAST-ONCE publication with idempotent
|
|
30
|
+
/// consumers, and it is stated that way rather than dressed up: a drainer that
|
|
31
|
+
/// dies after publishing but before marking will publish again once its claim
|
|
32
|
+
/// lease expires. Duplicates are harmless by construction because an event
|
|
33
|
+
/// carries identifiers rather than state — acting on it twice means re-reading
|
|
34
|
+
/// twice — and `id` travels as the emission id so a consumer that wants
|
|
35
|
+
/// explicit de-duplication has a stable key.
|
|
36
|
+
model RealtimeOutboxEvent {
|
|
37
|
+
id String @id @default(uuid())
|
|
38
|
+
/// Fully-qualified topic name, e.g. `tenant:<clientId>:orders`.
|
|
39
|
+
topic String
|
|
40
|
+
/// Dot-namespaced event type, stable on the wire: `orders.changed`.
|
|
41
|
+
type String
|
|
42
|
+
/// Identifiers only, never state — the payload rule the whole bus is built on.
|
|
43
|
+
data Json @default("{}")
|
|
44
|
+
|
|
45
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
46
|
+
/// Set once the event reached the bus. Null = still pending.
|
|
47
|
+
publishedAt DateTime? @map("published_at")
|
|
48
|
+
|
|
49
|
+
/// When a drainer took ownership. Null = free to claim.
|
|
50
|
+
///
|
|
51
|
+
/// A LEASE, not a lock: the holder may have died and nothing can tell the
|
|
52
|
+
/// difference from outside, so a claim older than the configured lease is
|
|
53
|
+
/// takeable. The claim itself is a conditional UPDATE, so the database
|
|
54
|
+
/// decides the winner — two drains racing one row cannot both publish it.
|
|
55
|
+
claimedAt DateTime? @map("claimed_at")
|
|
56
|
+
/// Which drainer holds the claim; the mark only ever matches its own holder.
|
|
57
|
+
claimedBy String? @map("claimed_by")
|
|
58
|
+
/// How many times this row has been CLAIMED (not how many times it failed).
|
|
59
|
+
///
|
|
60
|
+
/// Counting claims rather than failures is what bounds a poison row: a
|
|
61
|
+
/// drainer that crashes mid-publish records no failure, so a failure counter
|
|
62
|
+
/// would let a row that kills the process be retried forever.
|
|
63
|
+
attempts Int @default(0)
|
|
64
|
+
/// Why the last publish did not reach the bus, for the operator reading a
|
|
65
|
+
/// row that ran out of attempts.
|
|
66
|
+
lastError String? @map("last_error")
|
|
67
|
+
|
|
68
|
+
/// The drain's own read (pending rows oldest first), and the purge's
|
|
69
|
+
/// (published rows past a cutoff) — one composite serves both, because
|
|
70
|
+
/// `published_at` leads it. A separate `@@index([publishedAt])` is a
|
|
71
|
+
/// redundant PREFIX of this one: no query it could serve is unserved here,
|
|
72
|
+
/// and it costs a write amplification on every insert.
|
|
73
|
+
@@index([publishedAt, createdAt])
|
|
74
|
+
@@map("realtime_outbox_events")
|
|
75
|
+
}
|