@saasicat/adapter-prisma 1.0.0-rc.23 → 1.0.0-rc.24

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/README.md CHANGED
@@ -25,6 +25,7 @@ rows, which order, which lock — and every decision above that lives in
25
25
 
26
26
  ```ts
27
27
  import { prismaPersistence } from '@saasicat/adapter-prisma';
28
+ import { aesGcmSecretSealer } from '@saasicat/nest/platform';
28
29
  import { PrismaService } from './prisma/prisma.service';
29
30
 
30
31
  SaaSiCatModule.forRoot({
@@ -32,6 +33,8 @@ SaaSiCatModule.forRoot({
32
33
  controller: { guards: [JwtAuthGuard] },
33
34
  imports: [AuthModule],
34
35
  persistence: prismaPersistence({ client: PrismaService }),
36
+ // Not the bundle's to supply: the key that seals the SuperAdmin's second factor.
37
+ adapters: { secretSealer: aesGcmSecretSealer(process.env.SECRET_SEALER_KEY) },
35
38
  entitlement: {},
36
39
  });
37
40
  ```
@@ -47,10 +50,20 @@ Options:
47
50
 
48
51
  - `passwordHasher` — your `PasswordHasher` (token or instance). Enables
49
52
  `core.superAdminProvisioning` (setup wizard / `user create-super-admin`).
50
- - `rlsIntegration: true` — declare the `rowLevelSecurity` capability once
51
- your Prisma middleware really applies the bypass (see below).
53
+ - `rlsIntegration: true` — lift your row policies for what the platform does
54
+ across tenants (see [RLS bypass](#rls-bypass)).
52
55
  - `schema` — explicit plan identity, delegate and optional-field capabilities
53
56
  for schemas that differ from the 0.6 canonical layout.
57
+ - `notAdopted` — the canonical models your schema leaves out, as
58
+ `saasicat schema check` prints them (it hands over the list it can use). The
59
+ bundle leaves out what needs them, and a model it cannot do without is
60
+ refused with the ones it can. Leaving out `SuperAdminMfa` means passing your
61
+ own `MfaPort` in `adapters`; a start without one is refused. A ready client
62
+ passed as `client` needs no delegate of a model named here.
63
+ - `transactions` — `maxConcurrent`, the most platform transactions open at
64
+ once, and Prisma's `timeout` and `maxWait` for each. Unset, nothing is
65
+ bounded and Prisma's defaults apply; see
66
+ [Transactions under load](#transactions-under-load).
54
67
 
55
68
  The bundle also ships `planCatalogReadSink` for DB hydration. To use it,
56
69
  omit `planCatalog` and name the file the settings come from:
@@ -101,6 +114,11 @@ PromoCodesModule.forRoot({
101
114
  | `PrismaSubscriberLedgerRepository` | `SubscriberLedgerRepository` | `subscriber_ledger_entries` |
102
115
  | `PrismaAppliedSettingsRepository` | `AppliedSettingsPort` | `applied_settings`, `settings_changes` |
103
116
  | `PrismaMaintenanceWindowRepository` | `MaintenanceWindowPort` | `maintenance_windows` |
117
+ | `PrismaSubscriptionNoticeRepository` | `SubscriptionNoticeRepository` | `subscription_notices` |
118
+
119
+ `PrismaMfaAdapter` stores the secret it is handed. The platform seals it first with the
120
+ `SecretSealer` bound in `adapters`, which this bundle does not supply: the key belongs to the
121
+ installation, not to the database.
104
122
 
105
123
  Not shipped (custom adapters stay yours): registration persistence,
106
124
  consumer-specific payment/invoice integrations, and `FirstTimeCustomerCheck`.
@@ -124,6 +142,48 @@ providers: [
124
142
  builds without `prisma generate`, and any client generated from the
125
143
  canonical schema satisfies them.
126
144
 
145
+ ## Transactions under load
146
+
147
+ The platform's transactions take row locks and then read further, and each
148
+ holds a pooled connection for its whole life. Opened for every request at
149
+ once, they can end up holding every connection while waiting for a lock or for
150
+ a read that needs one — and the service stalls until Prisma's `timeout` aborts
151
+ them with `P2028`. `maxConcurrent` keeps some connections free: transactions
152
+ beyond it wait in arrival order before they open, and that wait does not count
153
+ against `timeout`.
154
+
155
+ ```ts
156
+ prismaPersistence({
157
+ client: PrismaService,
158
+ // A pool of 20 connections (`connection_limit` in the database URL).
159
+ transactions: { maxConcurrent: 15, timeout: 30_000, maxWait: 10_000 },
160
+ });
161
+ ```
162
+
163
+ Your pool size minus five is a sound start; too low a value queues work that
164
+ could have run side by side. `timeout` bounds what runs inside a transaction,
165
+ including the wait for a row lock, so a burst at a quota limit wants more than
166
+ Prisma's five seconds.
167
+
168
+ What is counted is what runs through `transactionRunner`. A repository called
169
+ without a transaction opens its own for that one call; it works on its own
170
+ handle only, so it cannot hold a connection while waiting for another, and it
171
+ is not counted. A transaction opened inside another — `run` called again rather
172
+ than `tx` passed on — needs a second place, and with every place taken waits
173
+ for the one its caller holds; pass `tx` on.
174
+
175
+ The bound belongs to the pool, not to a runner: every runner on one client
176
+ shares it, however many modules Nest builds one for, and a second, different
177
+ bound for the same client is refused. A transaction waits for its place as long
178
+ as it takes — the wait has no deadline, so a sustained overload queues rather
179
+ than fails; leave the bound headroom against your request timeouts. Wired by
180
+ hand, provide the options beside the runner:
181
+
182
+ ```ts
183
+ { provide: PRISMA_TRANSACTION_OPTIONS_TOKEN, useValue: { maxConcurrent: 15 } },
184
+ PrismaTransactionRunner,
185
+ ```
186
+
127
187
  ## Schema assumptions
128
188
 
129
189
  The canonical schema: copy the models from
@@ -138,8 +198,10 @@ the platform ports stay identical.
138
198
  ### Plan identity and split PlanVersion delegates
139
199
 
140
200
  The default is the SaaSiCat 0.6 layout: `PlanVersion.planId` stores the
141
- semantic `planKey`, both catalog and entitlement reads use the `planVersion`
142
- delegate, and optional validity columns are not queried. There is no schema
201
+ semantic `planKey`, and both catalog and entitlement reads use the `planVersion`
202
+ delegate. Every plan-version model carries `validFrom`, `validUntil` and
203
+ `endsAt`, as the canonical schema does: they decide which version is on sale,
204
+ for a catalogue, a price and a booking alike. There is no schema
143
205
  auto-detection.
144
206
 
145
207
  An app with a normalized UUID foreign key opts in explicitly. Port inputs and
@@ -148,16 +210,10 @@ outputs still use the semantic key:
148
210
  ```ts
149
211
  const schema = {
150
212
  planBinding: { mode: 'normalized-plan-id' },
151
- planVersionFields: {
152
- validityWindows: true,
153
- endsAt: true,
154
- },
155
213
  tenantSubscription: {
156
214
  subscriptionBundleDelegate: 'subscriptionBundle',
157
215
  synchronizePlanVersion: true,
158
216
  atomicOnboardingSelection: true,
159
- activeVersionSelection: 'validity-window',
160
- withEndsAt: true,
161
217
  },
162
218
  } satisfies PrismaSchemaOptions;
163
219
 
@@ -179,8 +235,8 @@ providers: [
179
235
  ```
180
236
 
181
237
  Split schemas can name catalog and entitlement delegates independently. This
182
- keeps, for example, `catalogPlanVersion` with validity columns separate from a
183
- legacy billing `planVersion`:
238
+ keeps, for example, `catalogPlanVersion` separate from a billing `planVersion`;
239
+ both carry the date columns:
184
240
 
185
241
  ```ts
186
242
  const schema = {
@@ -188,10 +244,6 @@ const schema = {
188
244
  catalogPlanVersion: 'catalogPlanVersion',
189
245
  entitlementPlanVersion: 'planVersion',
190
246
  },
191
- planVersionFields: {
192
- catalog: { validityWindows: true },
193
- entitlement: { validityWindows: false },
194
- },
195
247
  } satisfies PrismaSchemaOptions;
196
248
  ```
197
249
 
@@ -209,7 +261,7 @@ and optional promo callback share one Prisma transaction.
209
261
 
210
262
  Immediate changes and onboarding bind the subscription to the version they
211
263
  sell: they resolve the target plan's live PlanVersion and update `plan`,
212
- `planVersionId`, cycle and stale pending-version fields together. That is what
264
+ `planVersionId` and cycle together. That is what
213
265
  the entitlements read and what a contract freeze records, and it is the
214
266
  default. It needs a schema that carries it — a `planVersionId` column on the
215
267
  subscription model, the plan-version model (`schema.delegates.entitlementPlanVersion`,
@@ -240,40 +292,75 @@ is absent and the catalog service remains fail-closed.
240
292
 
241
293
  ### Bundle validity windows
242
294
 
243
- `PrismaBundleRepository` keeps its 0.6-compatible behavior by default and does
244
- not require `bundle_versions.validFrom` / `validUntil`. After applying the
245
- additive columns from the current `@saasicat/spec` bundle fragment, enable them
246
- explicitly:
247
-
248
- ```ts
249
- const bundles = new PrismaBundleRepository(prisma, {
250
- validityWindows: true,
251
- });
252
- ```
253
-
254
- The enabled mode persists and returns both dates. Publishing also sets the
255
- predecessor's `validUntil` to one UTC calendar day before the successor starts,
256
- and wraps supersede + publish in a transaction when the caller did not already
257
- provide one. It also exposes the optional
258
- `BundleRepository.findActiveBundleVersion(bundleId, asOf?)` capability, using
259
- inclusive UTC-day boundaries and preferring the highest `validFrom`, then
260
- `version`. In the default legacy mode that optional capability is `undefined`.
295
+ `PrismaBundleRepository` writes and reads `bundle_versions.validFrom` and
296
+ `validUntil`, as the canonical schema carries them: they decide which add-on
297
+ version is on sale, as they do for plans. Publishing sets the predecessor's
298
+ `validUntil` to one UTC calendar day before the successor starts, and wraps
299
+ supersede + publish in a transaction when the caller did not already provide
300
+ one. `BundleRepository.findActiveBundleVersion(bundleId, asOf?)` answers the
301
+ version on sale, using inclusive UTC-day boundaries and preferring the highest
302
+ `validFrom`, then `version`; a version superseded without a last day is not on
303
+ sale.
261
304
 
262
305
  ## RLS bypass
263
306
 
264
- `AsyncLocalRlsBypassAdapter` only toggles an `AsyncLocalStorage` flag — your
265
- `PrismaService` must apply it:
307
+ An operator's lists, the nightly sweeps and the platform's boot checks work
308
+ across tenants. Under a row policy that filters on the tenant they see nothing
309
+ of the others — an empty list, a count of 0, an update of no row — unless the
310
+ policy is lifted for them. `rlsIntegration: true` does that for every statement
311
+ the bundle's adapters run inside the platform's `runWithBypass`: a read, a
312
+ write, a raw statement, a batch, and an interactive transaction the platform's
313
+ runner or one of its repositories opens.
266
314
 
267
315
  ```ts
268
- this.$use(async (params, next) => {
269
- if (rls.isBypassActive()) {
270
- await this.$executeRawUnsafe('SET LOCAL row_security = off');
271
- }
272
- return next(params);
273
- });
316
+ prismaPersistence({ client: PrismaService, rlsIntegration: true });
317
+ ```
318
+
319
+ PostgreSQL has no per-statement switch for this. `SET row_security = off`
320
+ makes a query the policy would filter fail rather than see more, and a role
321
+ with `BYPASSRLS` skips every policy for every statement it runs. What the
322
+ bundle does instead is set `app.bypass_rls` to `'true'` for one transaction —
323
+ the statement's own, a batch's, or an interactive transaction's opened inside
324
+ the bypass — and your policy accepts that setting beside the tenant:
325
+
326
+ ```sql
327
+ ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
328
+ ALTER TABLE subscriptions FORCE ROW LEVEL SECURITY;
329
+ CREATE POLICY tenant_rows ON subscriptions
330
+ USING ("tenantId" = current_setting('app.tenant_id', true)
331
+ OR current_setting('app.bypass_rls', true) = 'true')
332
+ WITH CHECK ("tenantId" = current_setting('app.tenant_id', true)
333
+ OR current_setting('app.bypass_rls', true) = 'true');
274
334
  ```
275
335
 
276
- Only then pass `rlsIntegration: true` to `prismaPersistence`.
336
+ - **The application connects as a role the policy applies to**: not a
337
+ superuser, not a role with `BYPASSRLS`. The table owner is subject to it
338
+ only with `FORCE ROW LEVEL SECURITY`.
339
+ - **`app.tenant_id` is yours.** Setting it for a tenant's request is your
340
+ application's tenant scoping; the bundle sets only the bypass.
341
+ - **A transaction opened outside the bypass cannot enter it.** The setting
342
+ would outlast the bypass for the rest of that transaction, so a statement of
343
+ it that tries is refused with an error saying so. Open the transaction inside
344
+ `runWithBypass`; the platform's own code does. A statement on the client
345
+ itself, sent from inside a transaction's callback, runs on another connection
346
+ and is lifted on its own either way.
347
+ - **Where a statement runs is Prisma's to say.** It hands every query extension
348
+ the transaction a statement belongs to, outside its public types; Prisma 6,
349
+ which the suites here run against, does. A client that does not say is
350
+ refused inside the bypass rather than guessed at, since a wrong guess breaks
351
+ either the lifting or the transaction.
352
+ - **Another setting name**: `rlsIntegration: new PrismaRlsBypass('app.other')`.
353
+ - **Statements of your own** inside the platform's bypass — a job of yours
354
+ that injects `RLS_BYPASS_PORT_TOKEN` — go through the same instance: build
355
+ one, pass it as `rlsIntegration`, and run them on `bypass.extend(prisma)`.
356
+ Batch and open transactions for them on that client too: a batch or a
357
+ transaction opened on another client carries no setting, and a lifted
358
+ statement in it is refused.
359
+
360
+ An installation that keeps a bypass of its own, over its own tenant context,
361
+ binds its `RlsBypassPort` in `adapters` and leaves `rlsIntegration` out.
362
+ Without row policies, leave it out too: the bundle's port then runs the work
363
+ as it is.
277
364
 
278
365
  ## Tests
279
366
 
@@ -287,7 +374,9 @@ The integration run builds its schema from the normative reference SQL,
287
374
  generates a client from the composed fragments and executes the
288
375
  `@saasicat/persistence-testing` contract — CI does the same against a
289
376
  postgres service. **The database is disposable: the harness drops and
290
- recreates its `public` schema.**
377
+ recreates its `public` schema.** The RLS suite beside it creates the database
378
+ `<name>_rls` and the role `saasicat_rls_probe`, puts the policy above on a
379
+ table and runs the bundle as that role.
291
380
 
292
381
  ## Next
293
382
 
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- 821ef71efe4cd8c4e8e2ceadb8e1585dcfe868a6c2f2f8eaab6dad3bd6e04431
1
+ ea73c0e7bf28f59cc874609ad77c869e7129802a8297ffd6bd0f9ec21d3cabe9