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

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:
@@ -70,37 +83,44 @@ PromoCodesModule.forRoot({
70
83
 
71
84
  ## Shipped adapters
72
85
 
73
- | Class | Implements port | Tables |
74
- | ---------------------------------------- | ---------------------------------- | ------------------------------------------------------------------- |
75
- | `PrismaTransactionRunner` | `TransactionRunner` | — (`$transaction`) |
76
- | `PrismaMfaAdapter` | `MfaPort` | `super_admin_mfa` |
77
- | `PrismaAuditAdapter` | `AuditPort` | `audit_logs` |
78
- | `PrismaAuditQueryAdapter` | `AuditQueryPort` | `audit_logs` |
79
- | `PrismaAuditStatsAdapter` | `AuditStatsPort` | `audit_logs` |
80
- | `AsyncLocalRlsBypassAdapter` | `RlsBypassPort` | (no DB access) |
81
- | `PrismaSubscriptionRepository` | `SubscriptionRepository` | `subscriptions`, `plan_versions`, optionally `subscription_bundles` |
82
- | `PrismaSubscriptionBundleRepository` | `SubscriptionBundleRepository` | `subscription_bundles` |
83
- | `PrismaTenantSubscriptionWriteAdapter` | `TenantSubscriptionWritePort` | `subscriptions`, `plans`, `plan_versions` |
84
- | `PrismaPlanVersionRepository` | `PlanVersionRepository` | `plan_versions` |
85
- | `PrismaPromoCodeRepository` | `PromoCodeRepository` | `promo_codes` |
86
- | `PrismaPromoCodeRedemptionRepository` | `PromoCodeRedemptionRepository` | `promo_code_redemptions` |
87
- | `PrismaPromoCodeHoldRepository` | `PromoCodeHoldRepository` | `promo_code_holds`, `promo_codes` |
88
- | `PrismaPromoCodeValidationLogRepository` | `PromoCodeValidationLogRepository` | `promo_code_validation_logs` |
89
- | `PrismaPromoSubscriptionLookup` | `PromoSubscriptionLookup` | `subscriptions` |
90
- | `ZeroPromoRevenueDeductionAggregator` | `PromoRevenueDeductionAggregator` | — (constant `'0.00'`) |
91
- | `PrismaSuperAdminBootstrapAdapter` | `SuperAdminProvisioningPort` | `super_admin_users` |
92
- | `PrismaPlanCatalogReadSink` | `PlanCatalogReadSink` | `plans`, `plan_versions`, `feature_catalog_entries` |
93
- | `PrismaPlanCatalogImportSink` | `PlanCatalogImportSink` | same |
94
- | `PrismaPlanRepository` | `PlanRepository` | `plans`, `plan_versions` |
95
- | `PrismaBundleRepository` | `BundleRepository` | `bundles`, `bundle_versions` |
96
- | `PrismaCatalogEntryRepository` | `CatalogEntryRepository` | capability, feature and quota catalog tables |
97
- | `PrismaMarketingProjectionRepository` | `MarketingProjectionRepository` | `marketing_projections` |
98
- | `PrismaMarketingSettingsRepository` | `MarketingSettingsRepository` | `marketing_settings` |
99
- | `PrismaPromotionRepository` | `PromotionRepository` | `promotions` |
100
- | `PrismaSubscriptionContractRepository` | `SubscriptionContractRepository` | `subscription_contracts`, `contract_line_items` |
101
- | `PrismaSubscriberLedgerRepository` | `SubscriberLedgerRepository` | `subscriber_ledger_entries` |
102
- | `PrismaAppliedSettingsRepository` | `AppliedSettingsPort` | `applied_settings`, `settings_changes` |
103
- | `PrismaMaintenanceWindowRepository` | `MaintenanceWindowPort` | `maintenance_windows` |
86
+ | Class | Implements port | Tables |
87
+ | ----------------------------------------- | ----------------------------------- | ------------------------------------------------------------------- |
88
+ | `PrismaTransactionRunner` | `TransactionRunner` | — (`$transaction`) |
89
+ | `PrismaMfaAdapter` | `MfaPort` | `super_admin_mfa` |
90
+ | `PrismaAuditAdapter` | `AuditPort` | `audit_logs` |
91
+ | `PrismaAuditQueryAdapter` | `AuditQueryPort` | `audit_logs` |
92
+ | `PrismaAuditStatsAdapter` | `AuditStatsPort` | `audit_logs` |
93
+ | `AsyncLocalRlsBypassAdapter` | `RlsBypassPort` | (no DB access) |
94
+ | `PrismaSubscriptionRepository` | `SubscriptionRepository` | `subscriptions`, `plan_versions`, optionally `subscription_bundles` |
95
+ | `PrismaSubscriptionBundleRepository` | `SubscriptionBundleRepository` | `subscription_bundles` |
96
+ | `PrismaTenantSubscriptionWriteAdapter` | `TenantSubscriptionWritePort` | `subscriptions`, `plans`, `plan_versions` |
97
+ | `PrismaPlanVersionRepository` | `PlanVersionRepository` | `plan_versions` |
98
+ | `PrismaPromoCodeRepository` | `PromoCodeRepository` | `promo_codes` |
99
+ | `PrismaPromoCodeRedemptionRepository` | `PromoCodeRedemptionRepository` | `promo_code_redemptions` |
100
+ | `PrismaPromoCodeHoldRepository` | `PromoCodeHoldRepository` | `promo_code_holds`, `promo_codes` |
101
+ | `PrismaPromoCodeValidationLogRepository` | `PromoCodeValidationLogRepository` | `promo_code_validation_logs` |
102
+ | `PrismaPromoSubscriptionLookup` | `PromoSubscriptionLookup` | `subscriptions` |
103
+ | `ZeroPromoRevenueDeductionAggregator` | `PromoRevenueDeductionAggregator` | — (constant `'0.00'`) |
104
+ | `PrismaSuperAdminBootstrapAdapter` | `SuperAdminProvisioningPort` | `super_admin_users` |
105
+ | `PrismaPlanCatalogReadSink` | `PlanCatalogReadSink` | `plans`, `plan_versions`, `feature_catalog_entries` |
106
+ | `PrismaPlanCatalogImportSink` | `PlanCatalogImportSink` | same |
107
+ | `PrismaPlanRepository` | `PlanRepository` | `plans`, `plan_versions` |
108
+ | `PrismaBundleRepository` | `BundleRepository` | `bundles`, `bundle_versions` |
109
+ | `PrismaCatalogEntryRepository` | `CatalogEntryRepository` | capability, feature and quota catalog tables |
110
+ | `PrismaMarketingProjectionRepository` | `MarketingProjectionRepository` | `marketing_projections` |
111
+ | `PrismaMarketingSettingsRepository` | `MarketingSettingsRepository` | `marketing_settings` |
112
+ | `PrismaPromotionRepository` | `PromotionRepository` | `promotions` |
113
+ | `PrismaSubscriptionContractRepository` | `SubscriptionContractRepository` | `subscription_contracts`, `contract_line_items` |
114
+ | `PrismaSubscriberLedgerRepository` | `SubscriberLedgerRepository` | `subscriber_ledger_entries` |
115
+ | `PrismaAppliedSettingsRepository` | `AppliedSettingsPort` | `applied_settings`, `settings_changes` |
116
+ | `PrismaMaintenanceWindowRepository` | `MaintenanceWindowPort` | `maintenance_windows` |
117
+ | `PrismaSubscriptionNoticeRepository` | `SubscriptionNoticeRepository` | `subscription_notices` |
118
+ | `PrismaVersionRetirementRepository` | `VersionRetirementRepository` | `version_retirements` |
119
+ | `PrismaBundleVersionRetirementRepository` | `BundleVersionRetirementRepository` | `bundle_version_retirements` |
120
+
121
+ `PrismaMfaAdapter` stores the secret it is handed. The platform seals it first with the
122
+ `SecretSealer` bound in `adapters`, which this bundle does not supply: the key belongs to the
123
+ installation, not to the database.
104
124
 
105
125
  Not shipped (custom adapters stay yours): registration persistence,
106
126
  consumer-specific payment/invoice integrations, and `FirstTimeCustomerCheck`.
@@ -124,6 +144,48 @@ providers: [
124
144
  builds without `prisma generate`, and any client generated from the
125
145
  canonical schema satisfies them.
126
146
 
147
+ ## Transactions under load
148
+
149
+ The platform's transactions take row locks and then read further, and each
150
+ holds a pooled connection for its whole life. Opened for every request at
151
+ once, they can end up holding every connection while waiting for a lock or for
152
+ a read that needs one — and the service stalls until Prisma's `timeout` aborts
153
+ them with `P2028`. `maxConcurrent` keeps some connections free: transactions
154
+ beyond it wait in arrival order before they open, and that wait does not count
155
+ against `timeout`.
156
+
157
+ ```ts
158
+ prismaPersistence({
159
+ client: PrismaService,
160
+ // A pool of 20 connections (`connection_limit` in the database URL).
161
+ transactions: { maxConcurrent: 15, timeout: 30_000, maxWait: 10_000 },
162
+ });
163
+ ```
164
+
165
+ Your pool size minus five is a sound start; too low a value queues work that
166
+ could have run side by side. `timeout` bounds what runs inside a transaction,
167
+ including the wait for a row lock, so a burst at a quota limit wants more than
168
+ Prisma's five seconds.
169
+
170
+ What is counted is what runs through `transactionRunner`. A repository called
171
+ without a transaction opens its own for that one call; it works on its own
172
+ handle only, so it cannot hold a connection while waiting for another, and it
173
+ is not counted. A transaction opened inside another — `run` called again rather
174
+ than `tx` passed on — needs a second place, and with every place taken waits
175
+ for the one its caller holds; pass `tx` on.
176
+
177
+ The bound belongs to the pool, not to a runner: every runner on one client
178
+ shares it, however many modules Nest builds one for, and a second, different
179
+ bound for the same client is refused. A transaction waits for its place as long
180
+ as it takes — the wait has no deadline, so a sustained overload queues rather
181
+ than fails; leave the bound headroom against your request timeouts. Wired by
182
+ hand, provide the options beside the runner:
183
+
184
+ ```ts
185
+ { provide: PRISMA_TRANSACTION_OPTIONS_TOKEN, useValue: { maxConcurrent: 15 } },
186
+ PrismaTransactionRunner,
187
+ ```
188
+
127
189
  ## Schema assumptions
128
190
 
129
191
  The canonical schema: copy the models from
@@ -138,8 +200,10 @@ the platform ports stay identical.
138
200
  ### Plan identity and split PlanVersion delegates
139
201
 
140
202
  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
203
+ semantic `planKey`, and both catalog and entitlement reads use the `planVersion`
204
+ delegate. Every plan-version model carries `validFrom`, `validUntil` and
205
+ `endsAt`, as the canonical schema does: they decide which version is on sale,
206
+ for a catalogue, a price and a booking alike. There is no schema
143
207
  auto-detection.
144
208
 
145
209
  An app with a normalized UUID foreign key opts in explicitly. Port inputs and
@@ -148,16 +212,10 @@ outputs still use the semantic key:
148
212
  ```ts
149
213
  const schema = {
150
214
  planBinding: { mode: 'normalized-plan-id' },
151
- planVersionFields: {
152
- validityWindows: true,
153
- endsAt: true,
154
- },
155
215
  tenantSubscription: {
156
216
  subscriptionBundleDelegate: 'subscriptionBundle',
157
217
  synchronizePlanVersion: true,
158
218
  atomicOnboardingSelection: true,
159
- activeVersionSelection: 'validity-window',
160
- withEndsAt: true,
161
219
  },
162
220
  } satisfies PrismaSchemaOptions;
163
221
 
@@ -179,8 +237,8 @@ providers: [
179
237
  ```
180
238
 
181
239
  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`:
240
+ keeps, for example, `catalogPlanVersion` separate from a billing `planVersion`;
241
+ both carry the date columns:
184
242
 
185
243
  ```ts
186
244
  const schema = {
@@ -188,10 +246,6 @@ const schema = {
188
246
  catalogPlanVersion: 'catalogPlanVersion',
189
247
  entitlementPlanVersion: 'planVersion',
190
248
  },
191
- planVersionFields: {
192
- catalog: { validityWindows: true },
193
- entitlement: { validityWindows: false },
194
- },
195
249
  } satisfies PrismaSchemaOptions;
196
250
  ```
197
251
 
@@ -209,7 +263,7 @@ and optional promo callback share one Prisma transaction.
209
263
 
210
264
  Immediate changes and onboarding bind the subscription to the version they
211
265
  sell: they resolve the target plan's live PlanVersion and update `plan`,
212
- `planVersionId`, cycle and stale pending-version fields together. That is what
266
+ `planVersionId` and cycle together. That is what
213
267
  the entitlements read and what a contract freeze records, and it is the
214
268
  default. It needs a schema that carries it — a `planVersionId` column on the
215
269
  subscription model, the plan-version model (`schema.delegates.entitlementPlanVersion`,
@@ -240,40 +294,75 @@ is absent and the catalog service remains fail-closed.
240
294
 
241
295
  ### Bundle validity windows
242
296
 
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`.
297
+ `PrismaBundleRepository` writes and reads `bundle_versions.validFrom` and
298
+ `validUntil`, as the canonical schema carries them: they decide which add-on
299
+ version is on sale, as they do for plans. Publishing sets the predecessor's
300
+ `validUntil` to one UTC calendar day before the successor starts, and wraps
301
+ supersede + publish in a transaction when the caller did not already provide
302
+ one. `BundleRepository.findActiveBundleVersion(bundleId, asOf?)` answers the
303
+ version on sale, using inclusive UTC-day boundaries and preferring the highest
304
+ `validFrom`, then `version`; a version superseded without a last day is not on
305
+ sale.
261
306
 
262
307
  ## RLS bypass
263
308
 
264
- `AsyncLocalRlsBypassAdapter` only toggles an `AsyncLocalStorage` flag — your
265
- `PrismaService` must apply it:
309
+ An operator's lists, the nightly sweeps and the platform's boot checks work
310
+ across tenants. Under a row policy that filters on the tenant they see nothing
311
+ of the others — an empty list, a count of 0, an update of no row — unless the
312
+ policy is lifted for them. `rlsIntegration: true` does that for every statement
313
+ the bundle's adapters run inside the platform's `runWithBypass`: a read, a
314
+ write, a raw statement, a batch, and an interactive transaction the platform's
315
+ runner or one of its repositories opens.
266
316
 
267
317
  ```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
- });
318
+ prismaPersistence({ client: PrismaService, rlsIntegration: true });
319
+ ```
320
+
321
+ PostgreSQL has no per-statement switch for this. `SET row_security = off`
322
+ makes a query the policy would filter fail rather than see more, and a role
323
+ with `BYPASSRLS` skips every policy for every statement it runs. What the
324
+ bundle does instead is set `app.bypass_rls` to `'true'` for one transaction —
325
+ the statement's own, a batch's, or an interactive transaction's opened inside
326
+ the bypass — and your policy accepts that setting beside the tenant:
327
+
328
+ ```sql
329
+ ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
330
+ ALTER TABLE subscriptions FORCE ROW LEVEL SECURITY;
331
+ CREATE POLICY tenant_rows ON subscriptions
332
+ USING ("tenantId" = current_setting('app.tenant_id', true)
333
+ OR current_setting('app.bypass_rls', true) = 'true')
334
+ WITH CHECK ("tenantId" = current_setting('app.tenant_id', true)
335
+ OR current_setting('app.bypass_rls', true) = 'true');
274
336
  ```
275
337
 
276
- Only then pass `rlsIntegration: true` to `prismaPersistence`.
338
+ - **The application connects as a role the policy applies to**: not a
339
+ superuser, not a role with `BYPASSRLS`. The table owner is subject to it
340
+ only with `FORCE ROW LEVEL SECURITY`.
341
+ - **`app.tenant_id` is yours.** Setting it for a tenant's request is your
342
+ application's tenant scoping; the bundle sets only the bypass.
343
+ - **A transaction opened outside the bypass cannot enter it.** The setting
344
+ would outlast the bypass for the rest of that transaction, so a statement of
345
+ it that tries is refused with an error saying so. Open the transaction inside
346
+ `runWithBypass`; the platform's own code does. A statement on the client
347
+ itself, sent from inside a transaction's callback, runs on another connection
348
+ and is lifted on its own either way.
349
+ - **Where a statement runs is Prisma's to say.** It hands every query extension
350
+ the transaction a statement belongs to, outside its public types; Prisma 6,
351
+ which the suites here run against, does. A client that does not say is
352
+ refused inside the bypass rather than guessed at, since a wrong guess breaks
353
+ either the lifting or the transaction.
354
+ - **Another setting name**: `rlsIntegration: new PrismaRlsBypass('app.other')`.
355
+ - **Statements of your own** inside the platform's bypass — a job of yours
356
+ that injects `RLS_BYPASS_PORT_TOKEN` — go through the same instance: build
357
+ one, pass it as `rlsIntegration`, and run them on `bypass.extend(prisma)`.
358
+ Batch and open transactions for them on that client too: a batch or a
359
+ transaction opened on another client carries no setting, and a lifted
360
+ statement in it is refused.
361
+
362
+ An installation that keeps a bypass of its own, over its own tenant context,
363
+ binds its `RlsBypassPort` in `adapters` and leaves `rlsIntegration` out.
364
+ Without row policies, leave it out too: the bundle's port then runs the work
365
+ as it is.
277
366
 
278
367
  ## Tests
279
368
 
@@ -287,7 +376,9 @@ The integration run builds its schema from the normative reference SQL,
287
376
  generates a client from the composed fragments and executes the
288
377
  `@saasicat/persistence-testing` contract — CI does the same against a
289
378
  postgres service. **The database is disposable: the harness drops and
290
- recreates its `public` schema.**
379
+ recreates its `public` schema.** The RLS suite beside it creates the database
380
+ `<name>_rls` and the role `saasicat_rls_probe`, puts the policy above on a
381
+ table and runs the bundle as that role.
291
382
 
292
383
  ## Next
293
384
 
package/dist/.build-stamp CHANGED
@@ -1 +1 @@
1
- 821ef71efe4cd8c4e8e2ceadb8e1585dcfe868a6c2f2f8eaab6dad3bd6e04431
1
+ 14b4708a748d842d0a925ffcb3e181b338b3cdd02caf4163f69ac455485a4758