@12-apps/prisma 1.5.0 → 1.7.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.5.0",
3
+ "version": "1.7.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-audit-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-notifications-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-audit-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-notifications-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",
@@ -40,10 +40,14 @@
40
40
  "prisma:sync-plugins:check": "node scripts/sync-prisma-plugins.mjs --check",
41
41
  "prisma:sync-rbac": "node scripts/sync-rbac-schema.mjs",
42
42
  "prisma:sync-rbac:check": "node scripts/sync-rbac-schema.mjs --check",
43
+ "prisma:sync-notifications": "node scripts/sync-notifications-schema.mjs",
44
+ "prisma:sync-notifications:check": "node scripts/sync-notifications-schema.mjs --check",
43
45
  "prisma:sync-onboarding": "node scripts/sync-onboarding-schema.mjs",
44
46
  "prisma:sync-onboarding:check": "node scripts/sync-onboarding-schema.mjs --check",
45
47
  "prisma:sync-mcp": "node scripts/sync-mcp-schema.mjs",
46
48
  "prisma:sync-mcp:check": "node scripts/sync-mcp-schema.mjs --check",
49
+ "prisma:sync-realtime": "node scripts/sync-realtime-schema.mjs",
50
+ "prisma:sync-realtime:check": "node scripts/sync-realtime-schema.mjs --check",
47
51
  "prisma:sync-audit": "node scripts/sync-audit-schema.mjs",
48
52
  "prisma:sync-audit:check": "node scripts/sync-audit-schema.mjs --check"
49
53
  },
@@ -61,11 +65,13 @@
61
65
  "@12-apps/eslint-config": "^1.20.0",
62
66
  "@12-apps/jobs": "^2.0.0",
63
67
  "@12-apps/mcp": "^1.20.0",
68
+ "@12-apps/notifications": "^1.0.0",
64
69
  "@12-apps/onboarding": "^1.20.0",
65
- "@12-apps/payments-backend": "^2.0.0",
70
+ "@12-apps/payments-backend": "^2.0.1",
66
71
  "@12-apps/product-research": "^2.0.0",
67
72
  "@12-apps/rbac": "^2.0.0",
68
- "@12-apps/report-builder": "^3.0.0",
73
+ "@12-apps/realtime": "^1.19.0",
74
+ "@12-apps/report-builder": "^3.0.1",
69
75
  "@12-apps/shift": "^2.0.0",
70
76
  "@12-apps/typescript-config": "^1.20.0",
71
77
  "@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, including symlinked ones. */
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
- * `statSync` rather than `Dirent.isDirectory()` on purpose: a package that OWNS
21
- * part of the schema contributes its migrations as committed SYMLINKS into this
22
- * folder (Prisma has no cross-package import), and `Dirent.isDirectory()` is
23
- * FALSE for a symlink. That silently dropped those migrations and left the
24
- * package's tables missing from every PGlite-backed run with nothing failing
25
- * loudly, because the schema was merely incomplete until something queried
26
- * them. `statSync` follows the link, so an owned migration replays like any
27
- * other.
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,218 @@
1
+ -- @12-apps/notifications (12-15): the four generic notification tables, owned
2
+ -- by the package and copied into a host's migrations folder by its
3
+ -- plugin-migration sync. Runs identically on PostgreSQL + PGlite.
4
+ --
5
+ -- NO foreign keys into host tables (the payments-backend doctrine): `user_id`
6
+ -- and `client_id` are by-value scalars, and the host's own migration may add FK
7
+ -- constraints (recommended: both ON DELETE CASCADE). The relation INTERNAL to
8
+ -- the partial — notification_deliveries -> notifications — IS constrained, with
9
+ -- a cascade, so a purged notification can never leave orphan delivery rows.
10
+ --
11
+ -- `channel` and `status` carry CHECKs: those are the LIBRARY's own closed sets,
12
+ -- and a row outside them is a row no transport can carry. `category` does NOT,
13
+ -- for the reason `@12-apps/rbac` gives for `role`: the category set is host
14
+ -- vocabulary (`categories` on the server config), so a closed set here would be
15
+ -- wrong for every host but the first. A host that wants its own CHECK adds one.
16
+ --
17
+ -- The `status` set is QUEUED | SENDING | SENT | FAILED | DEAD. `SENDING` is the
18
+ -- dispatcher's CLAIM and `DEAD` is terminal (see `src/server/dispatch.ts`), and
19
+ -- both are newer than the first adopters' hand-made tables — which is why the
20
+ -- status CHECK below is DROPPED and re-added rather than guarded by a
21
+ -- `pg_constraint` lookup like the others. An existence guard would find the
22
+ -- three-value constraint an early adopter already has, skip, and leave the claim
23
+ -- rejected at runtime by a CHECK that predates it.
24
+ --
25
+ -- ============================ REPLAY SAFETY ================================
26
+ -- Every statement is guarded, because the first adopters ALREADY HAVE these
27
+ -- tables: future-pay created them by hand before the package existed, so this
28
+ -- migration must be a no-op there and correct on an empty database.
29
+ --
30
+ -- The guards are per COLUMN, not per table. `CREATE TABLE IF NOT EXISTS` alone
31
+ -- is the trap: it skips the whole table, so a host whose table predates a
32
+ -- column silently never gets that column and the failure surfaces later as a
33
+ -- missing-column error in production. So each table is followed by one
34
+ -- `ADD COLUMN IF NOT EXISTS` per column, and every NOT NULL column carries a
35
+ -- DEFAULT — a NOT NULL column with no default cannot be added to a table that
36
+ -- already holds rows.
37
+ --
38
+ -- CHECK constraints have no `IF NOT EXISTS` form, so they are guarded by a
39
+ -- `pg_constraint` lookup instead (plpgsql, which PGlite has) — EXCEPT the
40
+ -- delivery status CHECK, which must CONVERGE rather than be skipped and so is
41
+ -- `DROP CONSTRAINT IF EXISTS` + `ADD`. Same for the sweep's index, which moved
42
+ -- key: `DROP INDEX IF EXISTS` + `CREATE INDEX IF NOT EXISTS`. Both are
43
+ -- idempotent, which is the property replay safety actually needs — "guarded" was
44
+ -- only ever the usual way to get it, and it is the wrong way when the definition
45
+ -- itself has changed under an existing adopter.
46
+
47
+ -- ---------------------------------------------------------------------------
48
+ -- notifications — the always-on inbox. One row per emit, written before any
49
+ -- transport is consulted.
50
+ -- ---------------------------------------------------------------------------
51
+ CREATE TABLE IF NOT EXISTS "notifications" (
52
+ "id" TEXT NOT NULL,
53
+ "user_id" TEXT NOT NULL,
54
+ "client_id" TEXT,
55
+ "type" TEXT NOT NULL,
56
+ "category" TEXT NOT NULL,
57
+ "title" TEXT NOT NULL,
58
+ "body" TEXT NOT NULL,
59
+ "link" TEXT,
60
+ "data" JSONB NOT NULL DEFAULT '{}',
61
+ "read_at" TIMESTAMP(3),
62
+ "deleted_at" TIMESTAMP(3),
63
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
64
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
65
+
66
+ CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
67
+ );
68
+
69
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "user_id" TEXT NOT NULL DEFAULT '';
70
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "client_id" TEXT;
71
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "type" TEXT NOT NULL DEFAULT '';
72
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "category" TEXT NOT NULL DEFAULT 'system';
73
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "title" TEXT NOT NULL DEFAULT '';
74
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "body" TEXT NOT NULL DEFAULT '';
75
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "link" TEXT;
76
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "data" JSONB NOT NULL DEFAULT '{}';
77
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "read_at" TIMESTAMP(3);
78
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP(3);
79
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
80
+ ALTER TABLE "notifications" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
81
+
82
+ -- Inbox list (owner, not-deleted, newest first) + the unread badge count.
83
+ CREATE INDEX IF NOT EXISTS "notifications_user_id_deleted_at_created_at_idx"
84
+ ON "notifications"("user_id", "deleted_at", "created_at");
85
+ CREATE INDEX IF NOT EXISTS "notifications_user_id_deleted_at_read_at_idx"
86
+ ON "notifications"("user_id", "deleted_at", "read_at");
87
+
88
+ -- ---------------------------------------------------------------------------
89
+ -- notification_deliveries — one row per channel the router fanned out to.
90
+ -- ---------------------------------------------------------------------------
91
+ CREATE TABLE IF NOT EXISTS "notification_deliveries" (
92
+ "id" TEXT NOT NULL,
93
+ "notification_id" TEXT NOT NULL,
94
+ "channel" TEXT NOT NULL,
95
+ "status" TEXT NOT NULL DEFAULT 'QUEUED',
96
+ "error" TEXT,
97
+ "attempts" INTEGER NOT NULL DEFAULT 0,
98
+ "sent_at" TIMESTAMP(3),
99
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
100
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
101
+
102
+ CONSTRAINT "notification_deliveries_pkey" PRIMARY KEY ("id")
103
+ );
104
+
105
+ ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "notification_id" TEXT NOT NULL DEFAULT '';
106
+ ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "channel" TEXT NOT NULL DEFAULT 'EMAIL';
107
+ ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'QUEUED';
108
+ ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "error" TEXT;
109
+ -- The retry ceiling's counter (12-15). An adopter whose table predates it gets
110
+ -- it at 0, which reads as "never claimed" — the correct starting point for a row
111
+ -- that has, in the new lifecycle's terms, spent no attempts.
112
+ ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "attempts" INTEGER NOT NULL DEFAULT 0;
113
+ ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "sent_at" TIMESTAMP(3);
114
+ ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
115
+ ALTER TABLE "notification_deliveries" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
116
+
117
+ -- The library's own closed sets. A delivery outside them is a delivery no
118
+ -- transport can carry, so the schema refuses it rather than the router.
119
+ DO $$
120
+ BEGIN
121
+ IF NOT EXISTS (
122
+ SELECT 1 FROM pg_constraint WHERE conname = 'notification_deliveries_channel_check'
123
+ ) THEN
124
+ ALTER TABLE "notification_deliveries"
125
+ ADD CONSTRAINT "notification_deliveries_channel_check"
126
+ CHECK ("channel" IN ('EMAIL', 'SMS', 'WHATSAPP', 'WEB_PUSH'));
127
+ END IF;
128
+ END $$;
129
+
130
+ -- The status set, DROP-and-re-ADD rather than guarded (see the header): the
131
+ -- values widened in 12-15, so an adopter already holding a constraint under this
132
+ -- name holds the OLD three, and a guard would keep it — rejecting the claim's
133
+ -- own UPDATE. Idempotent, and re-running it converges rather than accumulating.
134
+ ALTER TABLE "notification_deliveries"
135
+ DROP CONSTRAINT IF EXISTS "notification_deliveries_status_check";
136
+ ALTER TABLE "notification_deliveries"
137
+ ADD CONSTRAINT "notification_deliveries_status_check"
138
+ CHECK ("status" IN ('QUEUED', 'SENDING', 'SENT', 'FAILED', 'DEAD'));
139
+
140
+ -- Idempotent fan-out: re-dispatching a notification can never duplicate a
141
+ -- channel's delivery row. This is what makes transport sends retry-safe.
142
+ CREATE UNIQUE INDEX IF NOT EXISTS "notification_deliveries_notification_id_channel_key"
143
+ ON "notification_deliveries"("notification_id", "channel");
144
+ -- Serves the retry sweep, which selects `status IN (…) AND updated_at < cutoff`.
145
+ -- On `updated_at`, not `created_at`: the sweep asks "has this row moved lately",
146
+ -- and `created_at` cannot answer that — a row re-queued a second ago still
147
+ -- carries a `created_at` from days back, so it reads as stale again immediately.
148
+ -- The `(status, created_at)` index this replaces served the earlier, wrong
149
+ -- predicate and is dropped rather than left behind to cost every write.
150
+ DROP INDEX IF EXISTS "notification_deliveries_status_created_at_idx";
151
+ CREATE INDEX IF NOT EXISTS "notification_deliveries_status_updated_at_idx"
152
+ ON "notification_deliveries"("status", "updated_at");
153
+
154
+ DO $$
155
+ BEGIN
156
+ IF NOT EXISTS (
157
+ SELECT 1 FROM pg_constraint WHERE conname = 'notification_deliveries_notification_id_fkey'
158
+ ) THEN
159
+ ALTER TABLE "notification_deliveries"
160
+ ADD CONSTRAINT "notification_deliveries_notification_id_fkey"
161
+ FOREIGN KEY ("notification_id") REFERENCES "notifications"("id")
162
+ ON DELETE CASCADE ON UPDATE CASCADE;
163
+ END IF;
164
+ END $$;
165
+
166
+ -- ---------------------------------------------------------------------------
167
+ -- notification_preferences — EXPLICIT choices only; no row means the defaults.
168
+ -- ---------------------------------------------------------------------------
169
+ CREATE TABLE IF NOT EXISTS "notification_preferences" (
170
+ "id" TEXT NOT NULL,
171
+ "user_id" TEXT NOT NULL,
172
+ "category" TEXT NOT NULL,
173
+ "channels" JSONB NOT NULL DEFAULT '{}',
174
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
175
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
176
+
177
+ CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id")
178
+ );
179
+
180
+ ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "user_id" TEXT NOT NULL DEFAULT '';
181
+ ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "category" TEXT NOT NULL DEFAULT 'system';
182
+ ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "channels" JSONB NOT NULL DEFAULT '{}';
183
+ ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
184
+ ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
185
+
186
+ CREATE UNIQUE INDEX IF NOT EXISTS "notification_preferences_user_id_category_key"
187
+ ON "notification_preferences"("user_id", "category");
188
+
189
+ -- ---------------------------------------------------------------------------
190
+ -- push_subscriptions — the Web Push destination, one row per browser.
191
+ -- ---------------------------------------------------------------------------
192
+ CREATE TABLE IF NOT EXISTS "push_subscriptions" (
193
+ "id" TEXT NOT NULL,
194
+ "user_id" TEXT NOT NULL,
195
+ "endpoint" TEXT NOT NULL,
196
+ "p256dh" TEXT NOT NULL,
197
+ "auth" TEXT NOT NULL,
198
+ "user_agent" TEXT,
199
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
200
+ "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
201
+
202
+ CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id")
203
+ );
204
+
205
+ ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "user_id" TEXT NOT NULL DEFAULT '';
206
+ ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "endpoint" TEXT NOT NULL DEFAULT '';
207
+ ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "p256dh" TEXT NOT NULL DEFAULT '';
208
+ ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "auth" TEXT NOT NULL DEFAULT '';
209
+ ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "user_agent" TEXT;
210
+ ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
211
+ ALTER TABLE "push_subscriptions" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
212
+
213
+ -- The push-service endpoint is globally unique per subscription — registering
214
+ -- the same browser again upserts rather than duplicates.
215
+ CREATE UNIQUE INDEX IF NOT EXISTS "push_subscriptions_endpoint_key"
216
+ ON "push_subscriptions"("endpoint");
217
+ CREATE INDEX IF NOT EXISTS "push_subscriptions_user_id_idx"
218
+ ON "push_subscriptions"("user_id");
@@ -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";
@@ -29,6 +29,8 @@
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"
32
+ "20260813120000_add_entity_lifecycle_tables",
33
+ "20260813140000_add_notification_tables",
34
+ "20260813180000_add_realtime_outbox"
33
35
  ]
34
36
  }
@@ -0,0 +1,141 @@
1
+ // @12-apps/notifications (12-15) — the four models the notification system
2
+ // owns, COPIED into a host's schema folder by
3
+ // `pnpm --filter @12-apps/notifications prisma:sync` (never symlinked: Prisma
4
+ // lstats a migration directory, so a symlinked migration is silently skipped
5
+ // and a green deploy applies no schema).
6
+ //
7
+ // Deliberately NO foreign keys into host tables (the payments-backend
8
+ // doctrine): `user_id` and `client_id` are by-value scalars, and the host's own
9
+ // migration may add FK constraints (recommended: both ON DELETE CASCADE, so a
10
+ // deleted account takes its inbox with it). The relation INTERNAL to the
11
+ // partial — notification_deliveries -> notifications — IS constrained, with a
12
+ // cascade, so a purged notification can never leave orphan delivery rows.
13
+ //
14
+ // Do not edit a synced copy by hand: the next sync reverts it and
15
+ // `prisma:sync:check` goes red in CI.
16
+
17
+ // One inbox entry — the always-on channel. Written for EVERY emit, before any
18
+ // transport is consulted, which is what makes "the user was told" true even
19
+ // when every provider is down. `type` is an open dot-namespaced set validated
20
+ // by the generator registry at the emit site (deliberately no CHECK, so a new
21
+ // notification type ships without a migration). `category` has NO CHECK either,
22
+ // and for a stronger reason: the category set is HOST vocabulary (`categories`
23
+ // on the server config), so a closed set in the schema would be wrong for every
24
+ // adopter but the first. A host that wants its own taxonomy enforced adds the
25
+ // CHECK in a migration of its own — future-pay does. Only `channel` and `status`
26
+ // on the delivery row are closed here, because those two are the LIBRARY's.
27
+ model Notification {
28
+ id String @id @default(uuid())
29
+ userId String @map("user_id")
30
+ clientId String? @map("client_id")
31
+ type String
32
+ category String
33
+ title String
34
+ body String
35
+ link String?
36
+ data Json @default("{}")
37
+ readAt DateTime? @map("read_at")
38
+ deletedAt DateTime? @map("deleted_at")
39
+ createdAt DateTime @default(now()) @map("created_at")
40
+ updatedAt DateTime @updatedAt @map("updated_at")
41
+
42
+ deliveries NotificationDelivery[]
43
+
44
+ // The inbox list (owner, not-deleted, newest first) and the unread-count
45
+ // badge (owner, not-deleted, unread) — both single-index scans.
46
+ @@index([userId, deletedAt, createdAt])
47
+ @@index([userId, deletedAt, readAt])
48
+ @@map("notifications")
49
+ }
50
+
51
+ // Per-channel delivery tracking for one Notification: one row per transport
52
+ // channel the router fanned out to, carrying the channel lifecycle and the
53
+ // provider error when a send fails. `channel` is EMAIL | SMS | WHATSAPP |
54
+ // WEB_PUSH (CHECK in the migration).
55
+ //
56
+ // The lifecycle is QUEUED -> SENDING -> SENT | FAILED | DEAD (String + DB
57
+ // CHECK), and the two states beyond the obvious three each remove a way to send
58
+ // somebody a duplicate paid message:
59
+ //
60
+ // - SENDING is the CLAIM. A dispatcher moves the row out of QUEUED with one
61
+ // conditional UPDATE and sends only if it moved exactly one row, so two
62
+ // dispatchers racing the same delivery make exactly one provider call.
63
+ // `attempts` is incremented by that same claim.
64
+ // - DEAD is TERMINAL, reached at the `attempts` ceiling. Without it a
65
+ // permanently invalid destination is a billed provider call on every sweep
66
+ // for the life of the row.
67
+ //
68
+ // `@@unique([notificationId, channel])` makes fan-out idempotent — re-dispatching
69
+ // a notification can never enqueue a duplicate delivery. Failures are isolated
70
+ // per row: one channel failing never blocks the inbox record or the others.
71
+ model NotificationDelivery {
72
+ id String @id @default(uuid())
73
+ notificationId String @map("notification_id")
74
+ channel String
75
+ status String @default("QUEUED")
76
+ error String?
77
+ /// Claims spent on this row. The retry ceiling counts these, not failures, so
78
+ /// a dispatcher that dies mid-send still spends one and cannot loop forever.
79
+ attempts Int @default(0)
80
+ sentAt DateTime? @map("sent_at")
81
+ createdAt DateTime @default(now()) @map("created_at")
82
+ updatedAt DateTime @updatedAt @map("updated_at")
83
+
84
+ notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
85
+
86
+ @@unique([notificationId, channel])
87
+ // Serves the retry sweep: `status IN (…) AND updated_at < cutoff`. On
88
+ // `updatedAt`, because "stale" is a question about the last write and
89
+ // `createdAt` cannot answer it — a row re-queued a second ago has an ancient
90
+ // `created_at` and would read as stale again immediately.
91
+ @@index([status, updatedAt])
92
+ @@map("notification_deliveries")
93
+ }
94
+
95
+ // A user's channel choices for ONE notification category: which of the
96
+ // transport channels may carry notifications of that category to them. One row
97
+ // per (user, category); NO row = the code-level defaults apply (email + web
98
+ // push on, the paid per-message channels off), so the table only ever stores
99
+ // EXPLICIT choices. The inbox is NOT a channel here — it is always on and not
100
+ // preference-gated.
101
+ //
102
+ // `channels` is a JSON map { "EMAIL": true, "SMS": false, … } rather than one
103
+ // boolean column per channel: the channel set is open-ended, and a new
104
+ // transport must not need a schema change — the open/closed rule the whole
105
+ // pipeline is built on. A stored row missing a channel key falls back to that
106
+ // channel's default, which is what makes the arrival of a channel a no-op for
107
+ // every existing row.
108
+ model NotificationPreference {
109
+ id String @id @default(uuid())
110
+ userId String @map("user_id")
111
+ category String
112
+ channels Json @default("{}")
113
+ createdAt DateTime @default(now()) @map("created_at")
114
+ updatedAt DateTime @updatedAt @map("updated_at")
115
+
116
+ @@unique([userId, category])
117
+ @@map("notification_preferences")
118
+ }
119
+
120
+ // One browser push subscription registered by a user's device — the Web Push
121
+ // transport's destination, exactly as `PushManager.subscribe()` returns it: the
122
+ // push-service `endpoint` (globally unique per subscription, hence @unique —
123
+ // re-registering the same browser upserts rather than duplicates) plus the
124
+ // `p256dh`/`auth` client keys the RFC 8291 payload encryption needs. A user may
125
+ // hold many rows (one per browser/device); the transport sends to all of them.
126
+ // A subscription the push service reports GONE (404/410) is pruned by the
127
+ // transport, so the table self-heals as browsers expire subscriptions.
128
+ model PushSubscription {
129
+ id String @id @default(uuid())
130
+ userId String @map("user_id")
131
+ endpoint String @unique
132
+ p256dh String
133
+ auth String
134
+ // Free-form browser/device hint ("Chrome · Linux") for a device list.
135
+ userAgent String? @map("user_agent")
136
+ createdAt DateTime @default(now()) @map("created_at")
137
+ updatedAt DateTime @updatedAt @map("updated_at")
138
+
139
+ @@index([userId])
140
+ @@map("push_subscriptions")
141
+ }
@@ -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
+ }