@saasicat/adapter-prisma 1.0.0-rc.22 → 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:
@@ -100,6 +113,12 @@ PromoCodesModule.forRoot({
100
113
  | `PrismaSubscriptionContractRepository` | `SubscriptionContractRepository` | `subscription_contracts`, `contract_line_items` |
101
114
  | `PrismaSubscriberLedgerRepository` | `SubscriberLedgerRepository` | `subscriber_ledger_entries` |
102
115
  | `PrismaAppliedSettingsRepository` | `AppliedSettingsPort` | `applied_settings`, `settings_changes` |
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.
103
122
 
104
123
  Not shipped (custom adapters stay yours): registration persistence,
105
124
  consumer-specific payment/invoice integrations, and `FirstTimeCustomerCheck`.
@@ -123,6 +142,48 @@ providers: [
123
142
  builds without `prisma generate`, and any client generated from the
124
143
  canonical schema satisfies them.
125
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
+
126
187
  ## Schema assumptions
127
188
 
128
189
  The canonical schema: copy the models from
@@ -137,8 +198,10 @@ the platform ports stay identical.
137
198
  ### Plan identity and split PlanVersion delegates
138
199
 
139
200
  The default is the SaaSiCat 0.6 layout: `PlanVersion.planId` stores the
140
- semantic `planKey`, both catalog and entitlement reads use the `planVersion`
141
- 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
142
205
  auto-detection.
143
206
 
144
207
  An app with a normalized UUID foreign key opts in explicitly. Port inputs and
@@ -147,16 +210,10 @@ outputs still use the semantic key:
147
210
  ```ts
148
211
  const schema = {
149
212
  planBinding: { mode: 'normalized-plan-id' },
150
- planVersionFields: {
151
- validityWindows: true,
152
- endsAt: true,
153
- },
154
213
  tenantSubscription: {
155
214
  subscriptionBundleDelegate: 'subscriptionBundle',
156
215
  synchronizePlanVersion: true,
157
216
  atomicOnboardingSelection: true,
158
- activeVersionSelection: 'validity-window',
159
- withEndsAt: true,
160
217
  },
161
218
  } satisfies PrismaSchemaOptions;
162
219
 
@@ -178,8 +235,8 @@ providers: [
178
235
  ```
179
236
 
180
237
  Split schemas can name catalog and entitlement delegates independently. This
181
- keeps, for example, `catalogPlanVersion` with validity columns separate from a
182
- legacy billing `planVersion`:
238
+ keeps, for example, `catalogPlanVersion` separate from a billing `planVersion`;
239
+ both carry the date columns:
183
240
 
184
241
  ```ts
185
242
  const schema = {
@@ -187,10 +244,6 @@ const schema = {
187
244
  catalogPlanVersion: 'catalogPlanVersion',
188
245
  entitlementPlanVersion: 'planVersion',
189
246
  },
190
- planVersionFields: {
191
- catalog: { validityWindows: true },
192
- entitlement: { validityWindows: false },
193
- },
194
247
  } satisfies PrismaSchemaOptions;
195
248
  ```
196
249
 
@@ -208,7 +261,7 @@ and optional promo callback share one Prisma transaction.
208
261
 
209
262
  Immediate changes and onboarding bind the subscription to the version they
210
263
  sell: they resolve the target plan's live PlanVersion and update `plan`,
211
- `planVersionId`, cycle and stale pending-version fields together. That is what
264
+ `planVersionId` and cycle together. That is what
212
265
  the entitlements read and what a contract freeze records, and it is the
213
266
  default. It needs a schema that carries it — a `planVersionId` column on the
214
267
  subscription model, the plan-version model (`schema.delegates.entitlementPlanVersion`,
@@ -239,40 +292,75 @@ is absent and the catalog service remains fail-closed.
239
292
 
240
293
  ### Bundle validity windows
241
294
 
242
- `PrismaBundleRepository` keeps its 0.6-compatible behavior by default and does
243
- not require `bundle_versions.validFrom` / `validUntil`. After applying the
244
- additive columns from the current `@saasicat/spec` bundle fragment, enable them
245
- explicitly:
246
-
247
- ```ts
248
- const bundles = new PrismaBundleRepository(prisma, {
249
- validityWindows: true,
250
- });
251
- ```
252
-
253
- The enabled mode persists and returns both dates. Publishing also sets the
254
- predecessor's `validUntil` to one UTC calendar day before the successor starts,
255
- and wraps supersede + publish in a transaction when the caller did not already
256
- provide one. It also exposes the optional
257
- `BundleRepository.findActiveBundleVersion(bundleId, asOf?)` capability, using
258
- inclusive UTC-day boundaries and preferring the highest `validFrom`, then
259
- `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.
260
304
 
261
305
  ## RLS bypass
262
306
 
263
- `AsyncLocalRlsBypassAdapter` only toggles an `AsyncLocalStorage` flag — your
264
- `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.
265
314
 
266
315
  ```ts
267
- this.$use(async (params, next) => {
268
- if (rls.isBypassActive()) {
269
- await this.$executeRawUnsafe('SET LOCAL row_security = off');
270
- }
271
- return next(params);
272
- });
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');
273
334
  ```
274
335
 
275
- 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.
276
364
 
277
365
  ## Tests
278
366
 
@@ -286,7 +374,9 @@ The integration run builds its schema from the normative reference SQL,
286
374
  generates a client from the composed fragments and executes the
287
375
  `@saasicat/persistence-testing` contract — CI does the same against a
288
376
  postgres service. **The database is disposable: the harness drops and
289
- 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.
290
380
 
291
381
  ## Next
292
382
 
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- bc8ca8ae3cba91e8cd52395cac265267fcfb1fd7174a169f629b5defba397d15
1
+ ea73c0e7bf28f59cc874609ad77c869e7129802a8297ffd6bd0f9ec21d3cabe9