@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 +135 -45
- package/dist/.build-stamp +1 -1
- package/dist/index.cjs +1519 -942
- package/dist/index.d.cts +349 -259
- package/dist/index.d.ts +349 -259
- package/dist/index.js +1452 -880
- 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:
|
|
@@ -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
|
|
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`
|
|
182
|
-
|
|
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
|
|
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`
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
ea73c0e7bf28f59cc874609ad77c869e7129802a8297ffd6bd0f9ec21d3cabe9
|