@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 +167 -76
- package/dist/.build-stamp +1 -1
- package/dist/index.cjs +1874 -1027
- package/dist/index.d.cts +355 -261
- package/dist/index.d.ts +355 -261
- package/dist/index.js +1734 -892
- package/package.json +4 -4
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` —
|
|
51
|
-
|
|
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
|
|
74
|
-
|
|
|
75
|
-
| `PrismaTransactionRunner`
|
|
76
|
-
| `PrismaMfaAdapter`
|
|
77
|
-
| `PrismaAuditAdapter`
|
|
78
|
-
| `PrismaAuditQueryAdapter`
|
|
79
|
-
| `PrismaAuditStatsAdapter`
|
|
80
|
-
| `AsyncLocalRlsBypassAdapter`
|
|
81
|
-
| `PrismaSubscriptionRepository`
|
|
82
|
-
| `PrismaSubscriptionBundleRepository`
|
|
83
|
-
| `PrismaTenantSubscriptionWriteAdapter`
|
|
84
|
-
| `PrismaPlanVersionRepository`
|
|
85
|
-
| `PrismaPromoCodeRepository`
|
|
86
|
-
| `PrismaPromoCodeRedemptionRepository`
|
|
87
|
-
| `PrismaPromoCodeHoldRepository`
|
|
88
|
-
| `PrismaPromoCodeValidationLogRepository`
|
|
89
|
-
| `PrismaPromoSubscriptionLookup`
|
|
90
|
-
| `ZeroPromoRevenueDeductionAggregator`
|
|
91
|
-
| `PrismaSuperAdminBootstrapAdapter`
|
|
92
|
-
| `PrismaPlanCatalogReadSink`
|
|
93
|
-
| `PrismaPlanCatalogImportSink`
|
|
94
|
-
| `PrismaPlanRepository`
|
|
95
|
-
| `PrismaBundleRepository`
|
|
96
|
-
| `PrismaCatalogEntryRepository`
|
|
97
|
-
| `PrismaMarketingProjectionRepository`
|
|
98
|
-
| `PrismaMarketingSettingsRepository`
|
|
99
|
-
| `PrismaPromotionRepository`
|
|
100
|
-
| `PrismaSubscriptionContractRepository`
|
|
101
|
-
| `PrismaSubscriberLedgerRepository`
|
|
102
|
-
| `PrismaAppliedSettingsRepository`
|
|
103
|
-
| `PrismaMaintenanceWindowRepository`
|
|
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
|
|
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`
|
|
183
|
-
|
|
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
|
|
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`
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
14b4708a748d842d0a925ffcb3e181b338b3cdd02caf4163f69ac455485a4758
|