@opensaas/stack-core 0.24.0 → 0.26.0
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +263 -0
- package/CLAUDE.md +50 -0
- package/dist/access/access-filter.d.ts +39 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +121 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-access.d.ts +1 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +79 -4
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +213 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/types.d.ts +39 -0
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/types.d.ts +318 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +36 -0
- package/dist/context/apply-defaults.d.ts.map +1 -0
- package/dist/context/apply-defaults.js +70 -0
- package/dist/context/apply-defaults.js.map +1 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +10 -0
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +79 -18
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +194 -39
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +59 -3
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +558 -129
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +91 -0
- package/dist/context/transaction-boundary.d.ts.map +1 -0
- package/dist/context/transaction-boundary.js +329 -0
- package/dist/context/transaction-boundary.js.map +1 -0
- package/dist/context/write-pipeline.d.ts +15 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +173 -10
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.d.ts +2 -0
- package/dist/fields/calendar-day.test.d.ts.map +1 -0
- package/dist/fields/calendar-day.test.js +120 -0
- package/dist/fields/calendar-day.test.js.map +1 -0
- package/dist/fields/index.d.ts +18 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +93 -17
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +116 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +154 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/validation/schema.test.js +222 -1
- package/dist/validation/schema.test.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.ts +156 -0
- package/src/access/field-access.test.ts +255 -0
- package/src/access/field-access.ts +91 -5
- package/src/access/index.ts +1 -1
- package/src/access/types.ts +45 -0
- package/src/config/types.ts +364 -0
- package/src/context/apply-defaults.ts +79 -0
- package/src/context/hook-pipeline.ts +11 -0
- package/src/context/index.ts +340 -68
- package/src/context/nested-operations.ts +976 -143
- package/src/context/transaction-boundary.ts +440 -0
- package/src/context/write-pipeline.ts +234 -13
- package/src/fields/calendar-day.test.ts +140 -0
- package/src/fields/index.ts +96 -16
- package/src/hooks/index.ts +265 -0
- package/src/index.ts +5 -0
- package/src/validation/schema.test.ts +266 -1
- package/tests/access.test.ts +24 -16
- package/tests/apply-defaults.test.ts +119 -0
- package/tests/context.test.ts +481 -0
- package/tests/default-value-create.test.ts +299 -0
- package/tests/field-types.test.ts +17 -3
- package/tests/interactive-transaction.test.ts +444 -0
- package/tests/nested-access-and-hooks.test.ts +1130 -54
- package/tests/nested-operation-registry.test.ts +28 -3
- package/tests/nested-write-hooks.test.ts +864 -0
- package/tests/sudo.test.ts +7 -3
- package/tests/transaction-boundary-hooks.test.ts +465 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/context/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
mergeFilters,
|
|
6
6
|
filterReadableFields,
|
|
7
7
|
buildIncludeWithAccessControl,
|
|
8
|
+
mergeIncludeWithAccessControl,
|
|
8
9
|
} from '../access/index.js'
|
|
9
10
|
import { ValidationError, DatabaseError } from '../hooks/index.js'
|
|
10
11
|
import { getDbKey } from '../lib/case-utils.js'
|
|
@@ -65,6 +66,72 @@ function isSingletonList(listConfig: ListConfig<any>): boolean {
|
|
|
65
66
|
return !!listConfig.isSingleton
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Compute the set of single-field unique selectors a `findUnique` `where` may be
|
|
71
|
+
* keyed by, derived from what the list config exposes at runtime.
|
|
72
|
+
*
|
|
73
|
+
* The set is:
|
|
74
|
+
* - `id` — always a unique identifier on every list.
|
|
75
|
+
* - Any field declared `isIndexed: 'unique'` in the config (e.g. `text({ isIndexed: 'unique' })`).
|
|
76
|
+
* - For a `relationship` field declared `isIndexed: 'unique'`, the foreign-key
|
|
77
|
+
* column name (`<field>Id`) — that is the column Prisma marks `@unique`, so the
|
|
78
|
+
* unique `where` is keyed by `<field>Id`, not the relation field itself.
|
|
79
|
+
*
|
|
80
|
+
* Chosen rule (documented intentionally): the config does NOT expose compound
|
|
81
|
+
* (`@@unique`) keys at runtime — there is no list-level unique declaration in the
|
|
82
|
+
* config API — so we cannot validate compound `<Model>_<a>_<b>` selectors. We
|
|
83
|
+
* therefore enforce the tractable subset: `where` must contain EXACTLY ONE
|
|
84
|
+
* recognised single-field unique key and NO other keys. This rejects non-unique
|
|
85
|
+
* filters (the bug in #567) and rejects extra non-unique keys alongside a unique
|
|
86
|
+
* one, while never falsely rejecting a valid single-field unique lookup. If a
|
|
87
|
+
* project legitimately needs a compound-unique lookup, that path is not covered
|
|
88
|
+
* here and would need explicit config support; the safe escape hatch for any
|
|
89
|
+
* non-unique single-row lookup is `findFirst` (see #565).
|
|
90
|
+
*/
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
92
|
+
function getUniqueWhereKeys(listConfig: ListConfig<any>): Set<string> {
|
|
93
|
+
const keys = new Set<string>(['id'])
|
|
94
|
+
|
|
95
|
+
for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
|
|
96
|
+
if (!fieldConfig || typeof fieldConfig !== 'object') continue
|
|
97
|
+
if (!('isIndexed' in fieldConfig) || fieldConfig.isIndexed !== 'unique') continue
|
|
98
|
+
|
|
99
|
+
if (fieldConfig.type === 'relationship') {
|
|
100
|
+
// A unique relationship's `@unique` lives on the FK column `<field>Id`.
|
|
101
|
+
keys.add(`${fieldKey}Id`)
|
|
102
|
+
} else {
|
|
103
|
+
keys.add(fieldKey)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return keys
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Enforce Keystone `findOne` semantics for `findUnique`: the caller-supplied
|
|
112
|
+
* `where` must be a valid unique selector. A non-unique `where` is a caller-shape
|
|
113
|
+
* error (not an access denial), so this THROWS rather than silently returning
|
|
114
|
+
* `null` — consistent with the fail-loud-on-misuse stance of PRD #581. A
|
|
115
|
+
* non-unique single-row lookup should use `findFirst` instead (see #565).
|
|
116
|
+
*/
|
|
117
|
+
function assertUniqueWhere(
|
|
118
|
+
where: Record<string, unknown> | undefined,
|
|
119
|
+
uniqueKeys: Set<string>,
|
|
120
|
+
listName: string,
|
|
121
|
+
): void {
|
|
122
|
+
const keys = where ? Object.keys(where) : []
|
|
123
|
+
|
|
124
|
+
const message =
|
|
125
|
+
`findUnique on "${listName}" requires a unique \`where\` (a single unique key such as ` +
|
|
126
|
+
`${Array.from(uniqueKeys).join(', ')}). ` +
|
|
127
|
+
`Received: ${keys.length === 0 ? '{}' : `{ ${keys.join(', ')} }`}. ` +
|
|
128
|
+
`Use \`findFirst\` for a non-unique single-row lookup.`
|
|
129
|
+
|
|
130
|
+
if (keys.length !== 1 || !uniqueKeys.has(keys[0])) {
|
|
131
|
+
throw new ValidationError([message], {})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
68
135
|
/**
|
|
69
136
|
* Check if auto-create is enabled for a singleton list
|
|
70
137
|
* Defaults to true if not explicitly set to false
|
|
@@ -163,6 +230,91 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
163
230
|
return new Error('An unknown error occurred')
|
|
164
231
|
}
|
|
165
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Database transaction isolation levels.
|
|
235
|
+
*
|
|
236
|
+
* Mirrors Prisma's `TransactionIsolationLevel`. The level passed to
|
|
237
|
+
* {@link StackContext.transaction} is forwarded to the underlying interactive
|
|
238
|
+
* transaction; provider support varies (e.g. `Serializable` is supported by
|
|
239
|
+
* PostgreSQL — required for the concurrency-sensitive capacity-gate pattern).
|
|
240
|
+
*/
|
|
241
|
+
export type TransactionIsolationLevel =
|
|
242
|
+
| 'ReadUncommitted'
|
|
243
|
+
| 'ReadCommitted'
|
|
244
|
+
| 'RepeatableRead'
|
|
245
|
+
| 'Serializable'
|
|
246
|
+
| 'Snapshot'
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Options for {@link StackContext.transaction}, forwarded verbatim to the
|
|
250
|
+
* underlying Prisma interactive transaction.
|
|
251
|
+
*/
|
|
252
|
+
export interface TransactionOptions {
|
|
253
|
+
/** Max ms to wait to acquire a transaction from the pool. */
|
|
254
|
+
maxWait?: number
|
|
255
|
+
/** Max ms the interactive transaction may run before timing out. */
|
|
256
|
+
timeout?: number
|
|
257
|
+
/** Isolation level for the transaction (e.g. `'Serializable'`). */
|
|
258
|
+
isolationLevel?: TransactionIsolationLevel
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Minimal shape of a Prisma client that can open an interactive transaction.
|
|
263
|
+
* A Prisma transaction client (the `tx` handed to the callback) intentionally
|
|
264
|
+
* does NOT expose `$transaction`, which is how nested writes detect they are
|
|
265
|
+
* already inside a transaction and join it rather than opening another.
|
|
266
|
+
*/
|
|
267
|
+
interface TransactionCapable<TPrisma> {
|
|
268
|
+
$transaction?: (
|
|
269
|
+
fn: (tx: TPrisma) => Promise<unknown>,
|
|
270
|
+
options?: TransactionOptions,
|
|
271
|
+
) => Promise<unknown>
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The access-controlled context returned by {@link getContext}.
|
|
276
|
+
*
|
|
277
|
+
* Exposes the secured `db` delegate plus the session, raw `prisma`, storage,
|
|
278
|
+
* plugin services, the generic `serverAction` handler, `sudo()` (bypasses access
|
|
279
|
+
* control but still runs hooks), and `transaction()` (interactive, hook-firing
|
|
280
|
+
* transaction). All access-checked operations run their list/field hooks.
|
|
281
|
+
*/
|
|
282
|
+
export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLike> {
|
|
283
|
+
db: AccessControlledDB<TPrisma>
|
|
284
|
+
session: Session | null
|
|
285
|
+
prisma: TPrisma
|
|
286
|
+
storage: StorageUtils
|
|
287
|
+
plugins: Record<string, unknown>
|
|
288
|
+
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
289
|
+
/**
|
|
290
|
+
* Run `fn` inside ONE interactive transaction. The `txContext` handed to `fn`
|
|
291
|
+
* is a full {@link StackContext} whose `db.*` operations are access-checked and
|
|
292
|
+
* hook-firing (identical to this context) but persist against the transaction
|
|
293
|
+
* client, so every write in the callback is atomic — a throw anywhere rolls the
|
|
294
|
+
* whole transaction back.
|
|
295
|
+
*
|
|
296
|
+
* `options` (notably `isolationLevel`) is forwarded to the underlying Prisma
|
|
297
|
+
* transaction. Serialization failures (e.g. Prisma `P2034`) propagate to the
|
|
298
|
+
* caller rather than being swallowed, so the caller can own a retry loop. If
|
|
299
|
+
* the client cannot open an interactive transaction (e.g. a plain mock, or we
|
|
300
|
+
* are already inside a transaction), `fn` runs directly against the current
|
|
301
|
+
* client with identical hook/access semantics.
|
|
302
|
+
*
|
|
303
|
+
* Caveat: plugin runtime services (`txContext.plugins`) stay bound to the
|
|
304
|
+
* top-level (non-transaction) client — they are shared services initialised
|
|
305
|
+
* once per request. Reads through a plugin service therefore won't see this
|
|
306
|
+
* transaction's uncommitted writes, and a plugin service that WRITES would
|
|
307
|
+
* escape the transaction and survive a rollback. Use `txContext.db` (not a
|
|
308
|
+
* plugin service) for writes that must be atomic with the transaction.
|
|
309
|
+
*/
|
|
310
|
+
transaction: <T>(
|
|
311
|
+
fn: (txContext: StackContext<TPrisma>) => Promise<T>,
|
|
312
|
+
options?: TransactionOptions,
|
|
313
|
+
) => Promise<T>
|
|
314
|
+
sudo: () => StackContext<TPrisma>
|
|
315
|
+
_isSudo: boolean
|
|
316
|
+
}
|
|
317
|
+
|
|
166
318
|
/**
|
|
167
319
|
* Create an access-controlled context
|
|
168
320
|
*
|
|
@@ -180,25 +332,10 @@ export function getContext<
|
|
|
180
332
|
session: Session | null,
|
|
181
333
|
storage?: StorageUtils,
|
|
182
334
|
_isSudo: boolean = false,
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
storage: StorageUtils
|
|
188
|
-
plugins: Record<string, unknown>
|
|
189
|
-
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
190
|
-
_isSudo: boolean
|
|
191
|
-
sudo: () => {
|
|
192
|
-
db: AccessControlledDB<TPrisma>
|
|
193
|
-
session: Session | null
|
|
194
|
-
prisma: TPrisma
|
|
195
|
-
storage: StorageUtils
|
|
196
|
-
plugins: Record<string, unknown>
|
|
197
|
-
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
198
|
-
sudo: () => unknown
|
|
199
|
-
_isSudo: boolean
|
|
200
|
-
}
|
|
201
|
-
} {
|
|
335
|
+
// Internal: when rebuilding the context against a transaction client, reuse the
|
|
336
|
+
// already-initialised plugin services rather than re-running plugin runtimes.
|
|
337
|
+
_sharedPlugins?: Record<string, unknown>,
|
|
338
|
+
): StackContext<TPrisma> {
|
|
202
339
|
// Initialize db object - will be populated with access-controlled operations
|
|
203
340
|
// Type is intentionally broad to allow dynamic model access
|
|
204
341
|
const db: Record<string, unknown> = {}
|
|
@@ -231,56 +368,30 @@ export function getContext<
|
|
|
231
368
|
)
|
|
232
369
|
},
|
|
233
370
|
},
|
|
234
|
-
|
|
371
|
+
// Reuse already-initialised plugin services when rebinding to a transaction
|
|
372
|
+
// client, otherwise start empty and populate via plugin runtimes below.
|
|
373
|
+
plugins: _sharedPlugins ?? {},
|
|
235
374
|
_isSudo,
|
|
236
375
|
_resolveOutputCounter: { depth: 0 },
|
|
237
376
|
}
|
|
238
377
|
|
|
239
|
-
// Create access-controlled operations for each list
|
|
240
|
-
|
|
241
|
-
const dbKey = getDbKey(listName)
|
|
378
|
+
// Create access-controlled operations for each list, populating `db` in place.
|
|
379
|
+
populateDbDelegate(db, config, prisma, context)
|
|
242
380
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const updateOp = createUpdate(listName, listConfig, prisma, context, config)
|
|
247
|
-
const operations: Record<string, unknown> = {
|
|
248
|
-
findUnique: createFindUnique(listName, listConfig, prisma, context, config),
|
|
249
|
-
findMany: findManyOp,
|
|
250
|
-
create: createOp,
|
|
251
|
-
update: updateOp,
|
|
252
|
-
delete: createDelete(listName, listConfig, prisma, context, config),
|
|
253
|
-
count: createCount(listName, listConfig, prisma, context),
|
|
254
|
-
createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
|
|
255
|
-
updateMany: createUpdateMany(
|
|
256
|
-
listName,
|
|
257
|
-
listConfig,
|
|
258
|
-
prisma,
|
|
259
|
-
context,
|
|
260
|
-
config,
|
|
261
|
-
findManyOp,
|
|
262
|
-
updateOp,
|
|
263
|
-
),
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Add get() method for singleton lists
|
|
267
|
-
if (isSingletonList(listConfig)) {
|
|
268
|
-
operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
db[dbKey] = operations
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Execute plugin runtime functions and populate context.plugins
|
|
381
|
+
// Execute plugin runtime functions and populate context.plugins.
|
|
382
|
+
// Skipped when reusing shared plugins (transaction rebind) so runtimes — and
|
|
383
|
+
// any side effects they carry — run exactly once per top-level context.
|
|
275
384
|
// Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
385
|
+
if (!_sharedPlugins) {
|
|
386
|
+
const pluginsToExecute = config._plugins || config.plugins || []
|
|
387
|
+
for (const plugin of pluginsToExecute) {
|
|
388
|
+
if (plugin.runtime) {
|
|
389
|
+
try {
|
|
390
|
+
context.plugins[plugin.name] = plugin.runtime(context)
|
|
391
|
+
} catch (error) {
|
|
392
|
+
console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
|
|
393
|
+
// Continue with other plugins even if one fails
|
|
394
|
+
}
|
|
284
395
|
}
|
|
285
396
|
}
|
|
286
397
|
}
|
|
@@ -376,11 +487,35 @@ export function getContext<
|
|
|
376
487
|
|
|
377
488
|
// Sudo function - creates a new context that bypasses access control
|
|
378
489
|
// but still executes all hooks and validation
|
|
379
|
-
function sudo() {
|
|
490
|
+
function sudo(): StackContext<TPrisma> {
|
|
380
491
|
return getContext(config, prisma, session, context.storage, true)
|
|
381
492
|
}
|
|
382
493
|
|
|
383
|
-
|
|
494
|
+
// Interactive, hook-firing transaction (#614). Rebinds the access-controlled
|
|
495
|
+
// context to the transaction client so every `txContext.db.*` write runs its
|
|
496
|
+
// access checks + hooks but persists inside ONE transaction (atomic). The
|
|
497
|
+
// transaction `options` (e.g. `isolationLevel`) pass through to Prisma, and a
|
|
498
|
+
// serialization failure thrown inside the callback propagates to the caller
|
|
499
|
+
// for retry (it is never converted to a silent `null`).
|
|
500
|
+
function transaction<T>(
|
|
501
|
+
fn: (txContext: StackContext<TPrisma>) => Promise<T>,
|
|
502
|
+
options?: TransactionOptions,
|
|
503
|
+
): Promise<T> {
|
|
504
|
+
const client = prisma as unknown as TransactionCapable<TPrisma>
|
|
505
|
+
if (typeof client.$transaction !== 'function') {
|
|
506
|
+
// No interactive transaction available — either a plain client/mock or we
|
|
507
|
+
// are already inside a transaction (a Prisma tx client exposes no
|
|
508
|
+
// `$transaction`). Run directly: hook/access semantics are identical and
|
|
509
|
+
// atomicity is provided by any enclosing transaction.
|
|
510
|
+
return fn(returned)
|
|
511
|
+
}
|
|
512
|
+
return client.$transaction(
|
|
513
|
+
(tx) => fn(getContext(config, tx, session, context.storage, _isSudo, context.plugins)),
|
|
514
|
+
options,
|
|
515
|
+
) as Promise<T>
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const returned: StackContext<TPrisma> = {
|
|
384
519
|
db: db as AccessControlledDB<TPrisma>,
|
|
385
520
|
session,
|
|
386
521
|
prisma,
|
|
@@ -388,8 +523,78 @@ export function getContext<
|
|
|
388
523
|
plugins: context.plugins,
|
|
389
524
|
serverAction,
|
|
390
525
|
sudo,
|
|
526
|
+
transaction,
|
|
391
527
|
_isSudo,
|
|
392
528
|
}
|
|
529
|
+
return returned
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Populate `target` with the access-controlled CRUD operations for every list,
|
|
534
|
+
* each bound to `prisma` and `context`. Used both by {@link getContext} (at
|
|
535
|
+
* request setup) and by the Write Pipeline to rebuild a `db` delegate against a
|
|
536
|
+
* transaction client (ADR-0010), so a hook's `context.db` write participates in
|
|
537
|
+
* the same transaction.
|
|
538
|
+
*
|
|
539
|
+
* The operations capture `prisma` at construction, so rebinding to a different
|
|
540
|
+
* client (e.g. a transaction `tx`) requires rebuilding the delegate — which is
|
|
541
|
+
* exactly what this function enables.
|
|
542
|
+
*/
|
|
543
|
+
export function populateDbDelegate<TPrisma extends PrismaClientLike>(
|
|
544
|
+
target: Record<string, unknown>,
|
|
545
|
+
config: OpenSaasConfig,
|
|
546
|
+
prisma: TPrisma,
|
|
547
|
+
context: AccessContext<TPrisma>,
|
|
548
|
+
): void {
|
|
549
|
+
for (const [listName, listConfig] of Object.entries(config.lists)) {
|
|
550
|
+
const dbKey = getDbKey(listName)
|
|
551
|
+
|
|
552
|
+
// Create base operations
|
|
553
|
+
const createOp = createCreate(listName, listConfig, prisma, context, config)
|
|
554
|
+
const findManyOp = createFindMany(listName, listConfig, prisma, context, config)
|
|
555
|
+
const updateOp = createUpdate(listName, listConfig, prisma, context, config)
|
|
556
|
+
const operations: Record<string, unknown> = {
|
|
557
|
+
findUnique: createFindUnique(listName, listConfig, prisma, context, config),
|
|
558
|
+
findMany: findManyOp,
|
|
559
|
+
findFirst: createFindFirst(findManyOp),
|
|
560
|
+
create: createOp,
|
|
561
|
+
update: updateOp,
|
|
562
|
+
delete: createDelete(listName, listConfig, prisma, context, config),
|
|
563
|
+
count: createCount(listName, listConfig, prisma, context),
|
|
564
|
+
createMany: createCreateMany(listName, listConfig, prisma, context, config, createOp),
|
|
565
|
+
updateMany: createUpdateMany(
|
|
566
|
+
listName,
|
|
567
|
+
listConfig,
|
|
568
|
+
prisma,
|
|
569
|
+
context,
|
|
570
|
+
config,
|
|
571
|
+
findManyOp,
|
|
572
|
+
updateOp,
|
|
573
|
+
),
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Add get() method for singleton lists
|
|
577
|
+
if (isSingletonList(listConfig)) {
|
|
578
|
+
operations.get = createGet(listName, listConfig, prisma, context, config, createOp)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
target[dbKey] = operations
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Build a fresh access-controlled `db` delegate bound to `prisma` and `context`.
|
|
587
|
+
* Convenience wrapper over {@link populateDbDelegate} returning a new object,
|
|
588
|
+
* used by the Write Pipeline to rebind `db` to a transaction client.
|
|
589
|
+
*/
|
|
590
|
+
export function buildDbDelegate<TPrisma extends PrismaClientLike>(
|
|
591
|
+
config: OpenSaasConfig,
|
|
592
|
+
prisma: TPrisma,
|
|
593
|
+
context: AccessContext<TPrisma>,
|
|
594
|
+
): AccessControlledDB<TPrisma> {
|
|
595
|
+
const db: Record<string, unknown> = {}
|
|
596
|
+
populateDbDelegate(db, config, prisma, context)
|
|
597
|
+
return db as AccessControlledDB<TPrisma>
|
|
393
598
|
}
|
|
394
599
|
|
|
395
600
|
/**
|
|
@@ -404,7 +609,10 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
404
609
|
config: OpenSaasConfig,
|
|
405
610
|
) {
|
|
406
611
|
return async (args: {
|
|
407
|
-
|
|
612
|
+
// Accepts any unique selector at the delegate level (the generated
|
|
613
|
+
// `<List>FindUniqueArgs` type narrows `where` to Prisma's `WhereUniqueInput`).
|
|
614
|
+
// The runtime guard below rejects non-unique shapes.
|
|
615
|
+
where: Record<string, unknown>
|
|
408
616
|
include?: Record<string, unknown>
|
|
409
617
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
410
618
|
query?: any
|
|
@@ -414,6 +622,15 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
414
622
|
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
415
623
|
warnIfSelectIgnored(args, listName, 'findUnique')
|
|
416
624
|
|
|
625
|
+
// Enforce unique-`where` (Keystone `findOne` parity). This is a caller-shape
|
|
626
|
+
// check independent of access, so it runs first and THROWS on misuse — it is
|
|
627
|
+
// not an access denial and must not be masked as a silent `null`. The
|
|
628
|
+
// type-level constraint already lives on the generated delegate: the custom
|
|
629
|
+
// `<List>FindUniqueArgs` only Omits `select`/`include` from
|
|
630
|
+
// `Prisma.<List>FindUniqueArgs`, so its `where` stays Prisma's
|
|
631
|
+
// `<List>WhereUniqueInput` — this runtime guard backstops untyped callers.
|
|
632
|
+
assertUniqueWhere(args.where, getUniqueWhereKeys(listConfig), listName)
|
|
633
|
+
|
|
417
634
|
// Check query access (skip if sudo mode)
|
|
418
635
|
let where: Record<string, unknown> = args.where
|
|
419
636
|
if (!context._isSudo) {
|
|
@@ -443,6 +660,10 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
443
660
|
|
|
444
661
|
if (fragment) {
|
|
445
662
|
include = buildInclude(fragment._fields) ?? undefined
|
|
663
|
+
} else if (context._isSudo) {
|
|
664
|
+
// Sudo bypasses access control entirely — the caller's include is trusted
|
|
665
|
+
// and used as-is (matching the prior behaviour); no per-relation filtering.
|
|
666
|
+
include = args.include
|
|
446
667
|
} else {
|
|
447
668
|
// Build include with access control filters
|
|
448
669
|
const accessControlledInclude = await buildIncludeWithAccessControl(
|
|
@@ -453,7 +674,18 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
453
674
|
},
|
|
454
675
|
config,
|
|
455
676
|
)
|
|
456
|
-
|
|
677
|
+
// MERGE (not replace) a caller-supplied include with the access-controlled
|
|
678
|
+
// include: the caller selects WHICH relations to fetch, access control
|
|
679
|
+
// decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
|
|
680
|
+
// caller include) still uses the access-controlled include directly.
|
|
681
|
+
include = args.include
|
|
682
|
+
? mergeIncludeWithAccessControl(
|
|
683
|
+
args.include,
|
|
684
|
+
accessControlledInclude,
|
|
685
|
+
listConfig.fields,
|
|
686
|
+
config,
|
|
687
|
+
)
|
|
688
|
+
: accessControlledInclude
|
|
457
689
|
}
|
|
458
690
|
|
|
459
691
|
// Execute query with optimized includes
|
|
@@ -551,6 +783,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
551
783
|
let include: Record<string, unknown> | undefined
|
|
552
784
|
if (fragment) {
|
|
553
785
|
include = buildInclude(fragment._fields) ?? undefined
|
|
786
|
+
} else if (context._isSudo) {
|
|
787
|
+
// Sudo bypasses access control entirely — the caller's include is trusted
|
|
788
|
+
// and used as-is (matching the prior behaviour); no per-relation filtering.
|
|
789
|
+
include = args?.include
|
|
554
790
|
} else {
|
|
555
791
|
// Build include with access control filters
|
|
556
792
|
const accessControlledInclude = await buildIncludeWithAccessControl(
|
|
@@ -561,7 +797,18 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
561
797
|
},
|
|
562
798
|
config,
|
|
563
799
|
)
|
|
564
|
-
|
|
800
|
+
// MERGE (not replace) a caller-supplied include with the access-controlled
|
|
801
|
+
// include: the caller selects WHICH relations to fetch, access control
|
|
802
|
+
// decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
|
|
803
|
+
// caller include) still uses the access-controlled include directly.
|
|
804
|
+
include = args?.include
|
|
805
|
+
? mergeIncludeWithAccessControl(
|
|
806
|
+
args.include,
|
|
807
|
+
accessControlledInclude,
|
|
808
|
+
listConfig.fields,
|
|
809
|
+
config,
|
|
810
|
+
)
|
|
811
|
+
: accessControlledInclude
|
|
565
812
|
}
|
|
566
813
|
|
|
567
814
|
// Execute query with optimized includes
|
|
@@ -603,6 +850,31 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
603
850
|
}
|
|
604
851
|
}
|
|
605
852
|
|
|
853
|
+
/**
|
|
854
|
+
* Create findFirst operation with access control.
|
|
855
|
+
*
|
|
856
|
+
* findFirst is sugar over the access-controlled findMany: it runs the exact same
|
|
857
|
+
* query-access checks and access-controlled include building as findMany, then
|
|
858
|
+
* returns the first matching row (or null when nothing matches). This introduces
|
|
859
|
+
* no new access surface — it inherits findMany's silent-failure contract (an
|
|
860
|
+
* access-denied query yields `[]`, which becomes `null` here).
|
|
861
|
+
*/
|
|
862
|
+
function createFindFirst(findManyOp: ReturnType<typeof createFindMany>) {
|
|
863
|
+
return async (args?: {
|
|
864
|
+
where?: Record<string, unknown>
|
|
865
|
+
orderBy?: Record<string, 'asc' | 'desc'> | Array<Record<string, 'asc' | 'desc'>>
|
|
866
|
+
skip?: number
|
|
867
|
+
include?: Record<string, unknown>
|
|
868
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
869
|
+
query?: any
|
|
870
|
+
// `select` is not honoured — accepted only so the no-op can be made visible.
|
|
871
|
+
select?: Record<string, unknown>
|
|
872
|
+
}) => {
|
|
873
|
+
const result = await findManyOp({ ...args, take: 1 })
|
|
874
|
+
return result[0] ?? null
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
606
878
|
/**
|
|
607
879
|
* Create create operation with access control and hooks
|
|
608
880
|
*/
|