@opensaas/stack-core 0.39.2 → 0.41.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 +189 -0
- package/CLAUDE.md +103 -0
- package/dist/access/access-filter.d.ts +157 -14
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +470 -42
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +982 -17
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +54 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +84 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +62 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +85 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +107 -15
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +9 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +11 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +98 -32
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +35 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +121 -27
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +74 -15
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +31 -34
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1528 -18
- package/src/access/access-filter.ts +708 -42
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +109 -1
- package/src/access/errors.ts +97 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +171 -25
- package/src/access/index.ts +25 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +98 -32
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +203 -42
- package/src/context/nested-operations.ts +96 -27
- package/src/context/write-pipeline.ts +60 -36
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/hooks/index.ts +22 -15
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +452 -17
- package/tests/field-types.test.ts +113 -0
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +389 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -2,6 +2,7 @@ import type { OpenSaasConfig, ListConfig } from '../config/types.js'
|
|
|
2
2
|
import type { AccessContext, PrismaClientLike } from '../access/types.js'
|
|
3
3
|
import {
|
|
4
4
|
checkAccess,
|
|
5
|
+
checkCreateAccess,
|
|
5
6
|
mergeFilters,
|
|
6
7
|
filterReadableFields,
|
|
7
8
|
filterWritableFields,
|
|
@@ -22,10 +23,11 @@ import { enumerateInvolvedLists, runWithTransactionBoundary } from './transactio
|
|
|
22
23
|
import { TransactionRegistry } from '../access/transaction-registry.js'
|
|
23
24
|
import { getDbKey } from '../lib/case-utils.js'
|
|
24
25
|
// NOTE: `index.ts` imports from this module too — this is an intentional cyclic
|
|
25
|
-
// dependency. It is safe because `
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
import {
|
|
26
|
+
// dependency. It is safe because `getContext` is only INVOKED at write time
|
|
27
|
+
// (never during module evaluation), so by the time it runs the export is fully
|
|
28
|
+
// initialised.
|
|
29
|
+
import { getContext } from './index.js'
|
|
30
|
+
import type { StackContext } from './index.js'
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Write Pipeline — runs the canonical, secured write sequence for one
|
|
@@ -165,6 +167,22 @@ export interface WritePipelineArgs<TPrisma extends PrismaClientLike> {
|
|
|
165
167
|
preResolvedTarget?: TargetResolution
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
/**
|
|
171
|
+
* {@link WritePipelineArgs} narrowed to the in-transaction phase: `context` is
|
|
172
|
+
* the full {@link StackContext} `bindContextToTransaction` rebuilds (issue
|
|
173
|
+
* #1176), not the plain {@link AccessContext} the pre-transaction gate and the
|
|
174
|
+
* transaction-boundary hooks use. `StackContext` is a structural superset of
|
|
175
|
+
* `AccessContext` (it carries `_resolveOutputChain`/`_transactionOwner` too),
|
|
176
|
+
* so nothing downstream of {@link runWriteInTransaction} that only needs
|
|
177
|
+
* `AccessContext` requires any change.
|
|
178
|
+
*/
|
|
179
|
+
type WriteInTransactionArgs<TPrisma extends PrismaClientLike> = Omit<
|
|
180
|
+
WritePipelineArgs<TPrisma>,
|
|
181
|
+
'context'
|
|
182
|
+
> & {
|
|
183
|
+
context: StackContext<TPrisma>
|
|
184
|
+
}
|
|
185
|
+
|
|
168
186
|
/**
|
|
169
187
|
* Run the canonical secured write sequence once. Phase order matches the
|
|
170
188
|
* "Write Pipeline" glossary entry in CONTEXT.md.
|
|
@@ -241,41 +259,44 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
|
|
|
241
259
|
}
|
|
242
260
|
|
|
243
261
|
/**
|
|
244
|
-
* Build
|
|
245
|
-
* client `tx`, so a `context.db` write a hook
|
|
246
|
-
* back with — this write's transaction
|
|
262
|
+
* Build the full {@link StackContext} a hook's `context` is (issue #1176):
|
|
263
|
+
* bound to the transaction client `tx`, so a `context.db` write a hook
|
|
264
|
+
* performs runs inside — and rolls back with — this write's transaction
|
|
265
|
+
* (ADR-0010), and carrying `sudo()`/`withSession()`/`transaction()` so a hook
|
|
266
|
+
* can reach an elevated or substituted read/write that stays on the SAME
|
|
267
|
+
* transaction client rather than escaping to the base one.
|
|
247
268
|
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* `_resolveOutputChain`
|
|
253
|
-
* keeps that hook's chain
|
|
269
|
+
* Goes through the same {@link getContext} factory `context.transaction()`
|
|
270
|
+
* already rebuilds through (ADR-0012), rather than hand-assembling a plain
|
|
271
|
+
* object literal — reusing the request context's `session`, `storage`,
|
|
272
|
+
* `_isSudo`, `plugins` (as `_sharedPlugins`, so plugin `runtime()` is NOT
|
|
273
|
+
* re-executed on the rebind), and `_resolveOutputChain` (so a write issued
|
|
274
|
+
* from inside a `resolveOutput` hook keeps that hook's cycle-guard chain,
|
|
275
|
+
* ADR-0023) as-is.
|
|
254
276
|
*
|
|
255
277
|
* `transactionOwner` (ADR-0028) is carried onto the rebuilt context so a hook's
|
|
256
|
-
* own `context.db` write
|
|
257
|
-
*
|
|
278
|
+
* own `context.db` write — and any write reached through its `sudo()`/
|
|
279
|
+
* `withSession()` — defers its transaction-boundary bracket to that owner
|
|
280
|
+
* instead of firing eagerly; `context.transaction()` called from a hook joins
|
|
281
|
+
* this same owner rather than opening a nested transaction, for the same
|
|
282
|
+
* reason.
|
|
258
283
|
*/
|
|
259
284
|
function bindContextToTransaction<TPrisma extends PrismaClientLike>(
|
|
260
285
|
args: WritePipelineArgs<TPrisma>,
|
|
261
286
|
tx: TPrisma,
|
|
262
287
|
transactionOwner: TransactionRegistry | undefined,
|
|
263
|
-
):
|
|
288
|
+
): StackContext<TPrisma> {
|
|
264
289
|
const { context, config } = args
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
// Rebuild `db` against `tx`, referencing `txContext` itself so hooks reached
|
|
276
|
-
// through it see the transactional context.
|
|
277
|
-
txContext.db = buildDbDelegate(config, tx, txContext)
|
|
278
|
-
return txContext
|
|
290
|
+
return getContext(
|
|
291
|
+
config,
|
|
292
|
+
tx,
|
|
293
|
+
context.session,
|
|
294
|
+
context.storage,
|
|
295
|
+
context._isSudo,
|
|
296
|
+
context.plugins,
|
|
297
|
+
transactionOwner,
|
|
298
|
+
context._resolveOutputChain,
|
|
299
|
+
)
|
|
279
300
|
}
|
|
280
301
|
|
|
281
302
|
/**
|
|
@@ -285,7 +306,7 @@ function bindContextToTransaction<TPrisma extends PrismaClientLike>(
|
|
|
285
306
|
* `runInTransaction` and rolls the transaction back.
|
|
286
307
|
*/
|
|
287
308
|
async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
|
|
288
|
-
args:
|
|
309
|
+
args: WriteInTransactionArgs<TPrisma>,
|
|
289
310
|
): Promise<Record<string, unknown> | null> {
|
|
290
311
|
const { listName, listConfig, prisma: tx, context, config, inputData, strategy } = args
|
|
291
312
|
const { operation } = strategy
|
|
@@ -330,6 +351,8 @@ async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
|
|
|
330
351
|
item: originalItem,
|
|
331
352
|
context: { ...context, _isSudo: context._isSudo },
|
|
332
353
|
inputData: input,
|
|
354
|
+
listName,
|
|
355
|
+
config,
|
|
333
356
|
})
|
|
334
357
|
|
|
335
358
|
// ── Phase 5.5: process nested relationship operations ───────────────────────
|
|
@@ -482,7 +505,7 @@ async function runDeletePath(args: {
|
|
|
482
505
|
listName: string
|
|
483
506
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
484
507
|
listConfig: ListConfig<any>
|
|
485
|
-
context:
|
|
508
|
+
context: StackContext
|
|
486
509
|
originalItem: Record<string, unknown> | undefined
|
|
487
510
|
model: PrismaModel
|
|
488
511
|
strategy: WriteStrategy
|
|
@@ -560,8 +583,9 @@ async function runDeletePath(args: {
|
|
|
560
583
|
* Create strategy for {@link WriteStrategy}.
|
|
561
584
|
*
|
|
562
585
|
* Axis 1: checks `create` access with no existing row; a filter result
|
|
563
|
-
*
|
|
564
|
-
*
|
|
586
|
+
* throws `InvalidCreateAccessResultError` rather than proceeding unchecked —
|
|
587
|
+
* create has no row to re-check a filter against, unlike update/delete
|
|
588
|
+
* (#1009). Enforces the singleton-create constraint even under sudo.
|
|
565
589
|
* Axis 2: runs all input phases.
|
|
566
590
|
* Axis 3: `model.create({ data })`, prepending `id: 1` for singleton lists.
|
|
567
591
|
*/
|
|
@@ -588,11 +612,11 @@ export function createWriteStrategy(
|
|
|
588
612
|
}
|
|
589
613
|
|
|
590
614
|
if (!context._isSudo) {
|
|
591
|
-
const
|
|
615
|
+
const allowed = await checkCreateAccess(listName, listConfig.access?.operation?.create, {
|
|
592
616
|
session: context.session,
|
|
593
617
|
context,
|
|
594
618
|
})
|
|
595
|
-
if (
|
|
619
|
+
if (!allowed) {
|
|
596
620
|
return { status: 'denied' }
|
|
597
621
|
}
|
|
598
622
|
}
|
package/src/fields/index.ts
CHANGED
|
@@ -229,9 +229,16 @@ export function integer<
|
|
|
229
229
|
modifiers += ` @map("${db.map}")`
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
// Unique modifier — non-unique index routes through `index` below,
|
|
233
|
+
// same as `text()`'s getPrismaType.
|
|
234
|
+
if (options?.isIndexed === 'unique') {
|
|
235
|
+
modifiers += ' @unique'
|
|
236
|
+
}
|
|
237
|
+
|
|
232
238
|
return {
|
|
233
239
|
type: 'Int',
|
|
234
240
|
modifiers: modifiers.trimStart() || undefined,
|
|
241
|
+
index: options?.isIndexed === true ? true : undefined,
|
|
235
242
|
}
|
|
236
243
|
},
|
|
237
244
|
getTypeScriptType: () => {
|
|
@@ -657,9 +664,16 @@ export function timestamp<
|
|
|
657
664
|
modifiers += ` @map("${db.map}")`
|
|
658
665
|
}
|
|
659
666
|
|
|
667
|
+
// Unique modifier — non-unique index routes through `index` below,
|
|
668
|
+
// same as `text()`'s getPrismaType.
|
|
669
|
+
if (options?.isIndexed === 'unique') {
|
|
670
|
+
modifiers += ' @unique'
|
|
671
|
+
}
|
|
672
|
+
|
|
660
673
|
return {
|
|
661
674
|
type: 'DateTime',
|
|
662
675
|
modifiers: modifiers.trimStart() || undefined,
|
|
676
|
+
index: options?.isIndexed === true ? true : undefined,
|
|
663
677
|
}
|
|
664
678
|
},
|
|
665
679
|
getTypeScriptType: () => {
|
|
@@ -930,7 +944,10 @@ function formatCalendarDay(value: unknown): string | null | undefined {
|
|
|
930
944
|
* ```
|
|
931
945
|
*
|
|
932
946
|
* **Important Notes:**
|
|
933
|
-
* - Password
|
|
947
|
+
* - Password field values are redacted to `{ isSet: boolean }` on serialization
|
|
948
|
+
* (`JSON.stringify`, the admin UI). Field-level `read` access is not denied by
|
|
949
|
+
* default — configure `access.field.read` if the raw value should never reach
|
|
950
|
+
* `context.db` callers at all.
|
|
934
951
|
* - Always use the `compare()` method to verify passwords - never compare strings directly
|
|
935
952
|
* - The password field value has type `HashedPassword` which extends string with compare()
|
|
936
953
|
* - Empty strings and undefined values are skipped (not hashed) to allow partial updates
|
|
@@ -955,6 +972,13 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
|
|
|
955
972
|
},
|
|
956
973
|
ui: {
|
|
957
974
|
...options?.ui,
|
|
975
|
+
// Excluded from default admin table columns (issue #1018) — declared
|
|
976
|
+
// via the flag rather than matched by field type/name, so an app can
|
|
977
|
+
// still opt a real password field back in with `ui.listView.defaultColumn: true`.
|
|
978
|
+
listView: {
|
|
979
|
+
defaultColumn: false,
|
|
980
|
+
...options?.ui?.listView,
|
|
981
|
+
},
|
|
958
982
|
valueForClientSerialization: ({ value }) => ({ isSet: !!value }),
|
|
959
983
|
},
|
|
960
984
|
hooks: {
|
|
@@ -1129,10 +1153,17 @@ export function select<
|
|
|
1129
1153
|
modifiers += ` @map("${options.db.map}")`
|
|
1130
1154
|
}
|
|
1131
1155
|
|
|
1156
|
+
// Unique modifier — non-unique index routes through `index` below,
|
|
1157
|
+
// same as `text()`'s getPrismaType.
|
|
1158
|
+
if (options.isIndexed === 'unique') {
|
|
1159
|
+
modifiers += ' @unique'
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1132
1162
|
return {
|
|
1133
1163
|
type: enumName,
|
|
1134
1164
|
modifiers: modifiers || undefined,
|
|
1135
1165
|
enumValues: options.options.map((opt) => opt.value),
|
|
1166
|
+
index: options.isIndexed === true ? true : undefined,
|
|
1136
1167
|
}
|
|
1137
1168
|
}
|
|
1138
1169
|
|
|
@@ -1146,9 +1177,14 @@ export function select<
|
|
|
1146
1177
|
modifiers += ` @map("${options.db.map}")`
|
|
1147
1178
|
}
|
|
1148
1179
|
|
|
1180
|
+
if (options.isIndexed === 'unique') {
|
|
1181
|
+
modifiers += ' @unique'
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1149
1184
|
return {
|
|
1150
1185
|
type: 'String',
|
|
1151
1186
|
modifiers: modifiers || undefined,
|
|
1187
|
+
index: options.isIndexed === true ? true : undefined,
|
|
1152
1188
|
}
|
|
1153
1189
|
},
|
|
1154
1190
|
getTypeScriptType: () => {
|
|
@@ -1366,6 +1402,18 @@ function computeManyToManyRelationName(
|
|
|
1366
1402
|
return undefined
|
|
1367
1403
|
}
|
|
1368
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* The name Prisma generation synthesizes for the back-relation a list-only
|
|
1407
|
+
* `ref` (`ref: 'ListName'`, no target field) creates on its target model —
|
|
1408
|
+
* Prisma requires an opposite field, and the config never declares one.
|
|
1409
|
+
* Exported so runtime nested-write resolution (`resolveSyntheticReverseRelation`
|
|
1410
|
+
* in `access/engine.ts`) can recognise the same name rather than re-deriving
|
|
1411
|
+
* the format by string parsing (#978).
|
|
1412
|
+
*/
|
|
1413
|
+
export function getSyntheticFieldName(listKey: string, fieldName: string): string {
|
|
1414
|
+
return `from_${listKey}_${fieldName}`
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1369
1417
|
function getPrismaRelation(
|
|
1370
1418
|
field: RelationshipField,
|
|
1371
1419
|
fieldName: string,
|
|
@@ -1378,7 +1426,7 @@ function getPrismaRelation(
|
|
|
1378
1426
|
// Synthetic back-relation for list-only refs (Prisma requires an opposite field)
|
|
1379
1427
|
let backRelation: PrismaRelationResult['backRelation']
|
|
1380
1428
|
if (!targetField) {
|
|
1381
|
-
const syntheticFieldName =
|
|
1429
|
+
const syntheticFieldName = getSyntheticFieldName(listKey, fieldName)
|
|
1382
1430
|
const relationName = field.db?.relationName ?? `${listKey}_${fieldName}`
|
|
1383
1431
|
backRelation = {
|
|
1384
1432
|
targetList,
|
|
@@ -1494,10 +1542,12 @@ export function relationship<
|
|
|
1494
1542
|
)
|
|
1495
1543
|
}
|
|
1496
1544
|
|
|
1497
|
-
if (refParts.length === 1) {
|
|
1545
|
+
if (refParts.length === 1 && typeof options.db.foreignKey === 'boolean') {
|
|
1498
1546
|
throw new Error(
|
|
1499
|
-
'db.foreignKey
|
|
1500
|
-
'List-only refs
|
|
1547
|
+
'db.foreignKey cannot be a boolean on list-only refs (ref: "ListName"). ' +
|
|
1548
|
+
'List-only refs always create foreign keys automatically, so the ownership sense of ' +
|
|
1549
|
+
'db.foreignKey is meaningless here. Use db.foreignKey: { map: "columnName" } to rename ' +
|
|
1550
|
+
'the foreign key column instead.',
|
|
1501
1551
|
)
|
|
1502
1552
|
}
|
|
1503
1553
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { relationship } from './index.js'
|
|
3
|
+
|
|
4
|
+
describe('relationship field builder: db.foreignKey validation', () => {
|
|
5
|
+
it('rejects a boolean db.foreignKey on a list-only ref', () => {
|
|
6
|
+
expect(() => relationship({ ref: 'User', db: { foreignKey: true } })).toThrow(
|
|
7
|
+
'db.foreignKey cannot be a boolean on list-only refs',
|
|
8
|
+
)
|
|
9
|
+
expect(() => relationship({ ref: 'User', db: { foreignKey: false } })).toThrow(
|
|
10
|
+
'db.foreignKey cannot be a boolean on list-only refs',
|
|
11
|
+
)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('allows an object db.foreignKey (map) on a list-only ref', () => {
|
|
15
|
+
expect(() =>
|
|
16
|
+
relationship({ ref: 'User', db: { foreignKey: { map: 'user_id' } } }),
|
|
17
|
+
).not.toThrow()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('still allows a boolean db.foreignKey on a bidirectional ref', () => {
|
|
21
|
+
expect(() => relationship({ ref: 'User.posts', db: { foreignKey: true } })).not.toThrow()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('still rejects db.foreignKey on a many relationship regardless of ref shape', () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
relationship({ ref: 'User', many: true, db: { foreignKey: { map: 'user_id' } } }),
|
|
27
|
+
).toThrow('db.foreignKey can only be used on single relationships')
|
|
28
|
+
})
|
|
29
|
+
})
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { Hooks } from '../config/types.js'
|
|
2
2
|
import type { AccessContext } from '../access/types.js'
|
|
3
3
|
import type { FieldConfig } from '../config/types.js'
|
|
4
|
+
// #1176: resolveInput/validate/beforeOperation/afterOperation (list AND field
|
|
5
|
+
// level) receive the full secured `StackContext` — sudo()/withSession()/
|
|
6
|
+
// transaction() bound to the write's own transaction client — not the plain
|
|
7
|
+
// `AccessContext`. Transaction-boundary hooks (beforeTransaction/
|
|
8
|
+
// afterTransaction) are unaffected: they stay on `AccessContext`/the base
|
|
9
|
+
// client by ADR-0028 design.
|
|
10
|
+
import type { StackContext } from '../context/index.js'
|
|
4
11
|
import { validateWithZod } from '../validation/schema.js'
|
|
5
12
|
import { checkFieldAccess } from '../access/field-access.js'
|
|
6
13
|
|
|
@@ -42,7 +49,7 @@ export async function executeResolveInput<
|
|
|
42
49
|
inputData: TCreateInput
|
|
43
50
|
resolvedData: TCreateInput
|
|
44
51
|
item: undefined
|
|
45
|
-
context:
|
|
52
|
+
context: StackContext
|
|
46
53
|
}
|
|
47
54
|
| {
|
|
48
55
|
listKey: string
|
|
@@ -50,7 +57,7 @@ export async function executeResolveInput<
|
|
|
50
57
|
inputData: TUpdateInput
|
|
51
58
|
resolvedData: TUpdateInput
|
|
52
59
|
item: TOutput
|
|
53
|
-
context:
|
|
60
|
+
context: StackContext
|
|
54
61
|
},
|
|
55
62
|
): Promise<TCreateInput | TUpdateInput> {
|
|
56
63
|
if (!hooks?.resolveInput) {
|
|
@@ -75,7 +82,7 @@ export async function executeValidate<
|
|
|
75
82
|
inputData: TCreateInput
|
|
76
83
|
resolvedData: TCreateInput
|
|
77
84
|
item: undefined
|
|
78
|
-
context:
|
|
85
|
+
context: StackContext
|
|
79
86
|
}
|
|
80
87
|
| {
|
|
81
88
|
listKey: string
|
|
@@ -83,13 +90,13 @@ export async function executeValidate<
|
|
|
83
90
|
inputData: TUpdateInput
|
|
84
91
|
resolvedData: TUpdateInput
|
|
85
92
|
item: TOutput
|
|
86
|
-
context:
|
|
93
|
+
context: StackContext
|
|
87
94
|
}
|
|
88
95
|
| {
|
|
89
96
|
listKey: string
|
|
90
97
|
operation: 'delete'
|
|
91
98
|
item: TOutput
|
|
92
|
-
context:
|
|
99
|
+
context: StackContext
|
|
93
100
|
},
|
|
94
101
|
): Promise<void> {
|
|
95
102
|
const validateHook = hooks?.validate || hooks?.validateInput
|
|
@@ -131,7 +138,7 @@ export async function executeBeforeOperation<
|
|
|
131
138
|
operation: 'create'
|
|
132
139
|
inputData: TCreateInput
|
|
133
140
|
resolvedData: TCreateInput
|
|
134
|
-
context:
|
|
141
|
+
context: StackContext
|
|
135
142
|
}
|
|
136
143
|
| {
|
|
137
144
|
listKey: string
|
|
@@ -139,13 +146,13 @@ export async function executeBeforeOperation<
|
|
|
139
146
|
inputData: TUpdateInput
|
|
140
147
|
item: TOutput
|
|
141
148
|
resolvedData: TUpdateInput
|
|
142
|
-
context:
|
|
149
|
+
context: StackContext
|
|
143
150
|
}
|
|
144
151
|
| {
|
|
145
152
|
listKey: string
|
|
146
153
|
operation: 'delete'
|
|
147
154
|
item: TOutput
|
|
148
|
-
context:
|
|
155
|
+
context: StackContext
|
|
149
156
|
},
|
|
150
157
|
): Promise<void> {
|
|
151
158
|
if (!hooks?.beforeOperation) {
|
|
@@ -168,7 +175,7 @@ export async function executeAfterOperation<
|
|
|
168
175
|
inputData: TCreateInput
|
|
169
176
|
item: TOutput
|
|
170
177
|
resolvedData: TCreateInput
|
|
171
|
-
context:
|
|
178
|
+
context: StackContext
|
|
172
179
|
}
|
|
173
180
|
| {
|
|
174
181
|
listKey: string
|
|
@@ -177,13 +184,13 @@ export async function executeAfterOperation<
|
|
|
177
184
|
originalItem: TOutput
|
|
178
185
|
item: TOutput
|
|
179
186
|
resolvedData: TUpdateInput
|
|
180
|
-
context:
|
|
187
|
+
context: StackContext
|
|
181
188
|
}
|
|
182
189
|
| {
|
|
183
190
|
listKey: string
|
|
184
191
|
operation: 'delete'
|
|
185
192
|
originalItem: TOutput
|
|
186
|
-
context:
|
|
193
|
+
context: StackContext
|
|
187
194
|
},
|
|
188
195
|
): Promise<void> {
|
|
189
196
|
if (!hooks?.afterOperation) {
|
|
@@ -472,7 +479,7 @@ export async function executeFieldResolveInputHooks(
|
|
|
472
479
|
resolvedData: Record<string, any>,
|
|
473
480
|
fields: Record<string, FieldConfig>,
|
|
474
481
|
operation: 'create' | 'update',
|
|
475
|
-
context:
|
|
482
|
+
context: StackContext,
|
|
476
483
|
listKey: string,
|
|
477
484
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
478
485
|
item?: any,
|
|
@@ -575,7 +582,7 @@ export async function executeFieldValidateHooks(
|
|
|
575
582
|
resolvedData: Record<string, any> | undefined,
|
|
576
583
|
fields: Record<string, FieldConfig>,
|
|
577
584
|
operation: 'create' | 'update' | 'delete',
|
|
578
|
-
context:
|
|
585
|
+
context: StackContext,
|
|
579
586
|
listKey: string,
|
|
580
587
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
581
588
|
item?: any,
|
|
@@ -640,7 +647,7 @@ export async function executeFieldBeforeOperationHooks(
|
|
|
640
647
|
resolvedData: Record<string, any>,
|
|
641
648
|
fields: Record<string, FieldConfig>,
|
|
642
649
|
operation: 'create' | 'update' | 'delete',
|
|
643
|
-
context:
|
|
650
|
+
context: StackContext,
|
|
644
651
|
listKey: string,
|
|
645
652
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
646
653
|
item?: any,
|
|
@@ -687,7 +694,7 @@ export async function executeFieldAfterOperationHooks(
|
|
|
687
694
|
resolvedData: Record<string, unknown> | undefined,
|
|
688
695
|
fields: Record<string, FieldConfig>,
|
|
689
696
|
operation: 'create' | 'update' | 'delete',
|
|
690
|
-
context:
|
|
697
|
+
context: StackContext,
|
|
691
698
|
listKey: string,
|
|
692
699
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
693
700
|
originalItem?: any,
|
package/src/index.ts
CHANGED
|
@@ -50,7 +50,7 @@ export type {
|
|
|
50
50
|
} from './context/index.js'
|
|
51
51
|
|
|
52
52
|
// Naming utilities (documented public helpers; used for URLs and db keys)
|
|
53
|
-
export { getDbKey, getUrlKey, getListKeyFromUrl } from './lib/case-utils.js'
|
|
53
|
+
export { getDbKey, getUrlKey, getListKeyFromUrl, resolveListKeyFromUrl } from './lib/case-utils.js'
|
|
54
54
|
|
|
55
55
|
// Label seam — resolves the field that represents a row as a single label
|
|
56
56
|
// (projection) and reads it off a row (render). Used by the admin UI for
|
|
@@ -64,6 +64,14 @@ export { resolveNavCounts, isListQueryStaticallyDenied } from './config/nav-coun
|
|
|
64
64
|
// Validation error surfaced by write operations
|
|
65
65
|
export { ValidationError } from './hooks/index.js'
|
|
66
66
|
|
|
67
|
+
// Resolves which columns (and, where recoverable, which named constraint) a
|
|
68
|
+
// caught P2002 unique-constraint violation hit — normalising Prisma 7 driver
|
|
69
|
+
// adapters' undocumented error shape to the documented `meta.target` one, so
|
|
70
|
+
// a caller of `context.db.*` never needs to reach into adapter internals
|
|
71
|
+
// (see issue #979).
|
|
72
|
+
export { uniqueConstraintOf } from './lib/prisma-errors.js'
|
|
73
|
+
export type { UniqueConstraintInfo } from './lib/prisma-errors.js'
|
|
74
|
+
|
|
67
75
|
// Thrown by a read when a caller-supplied `include` names a relation nested
|
|
68
76
|
// deeper than the Access Filter can scope (see ADR-0022). Distinct from
|
|
69
77
|
// `ValidationError` — this is the engine refusing to return unscoped data, not
|
|
@@ -83,6 +91,14 @@ export { ResolveOutputCycleError } from './access/index.js'
|
|
|
83
91
|
// validation failure.
|
|
84
92
|
export { InvalidFieldAccessResultError } from './access/index.js'
|
|
85
93
|
|
|
94
|
+
// Thrown by the write pipeline's create strategy and by the nested-create
|
|
95
|
+
// path when operation-level `create` access returns anything other than a
|
|
96
|
+
// strict boolean (see #1009, ADR-0022, and ADR-0030). A filter — the shape
|
|
97
|
+
// `query`/`update`/`delete` legitimately return — cannot be honoured on
|
|
98
|
+
// create: there is no existing row and no way to test it against input data,
|
|
99
|
+
// so it is refused loudly rather than silently treated as a full allow.
|
|
100
|
+
export { InvalidCreateAccessResultError } from './access/index.js'
|
|
101
|
+
|
|
86
102
|
// Thrown by a read when a caller-supplied `where` filters on a relation whose
|
|
87
103
|
// related list denies operation-level `query` access outright (see #916 and
|
|
88
104
|
// ADR-0022). Distinct from `ValidationError` for the same reason as
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getDbKey,
|
|
8
8
|
getUrlKey,
|
|
9
9
|
getListKeyFromUrl,
|
|
10
|
+
resolveListKeyFromUrl,
|
|
10
11
|
} from './case-utils.js'
|
|
11
12
|
|
|
12
13
|
describe('Case Conversion Utilities', () => {
|
|
@@ -97,6 +98,37 @@ describe('Case Conversion Utilities', () => {
|
|
|
97
98
|
})
|
|
98
99
|
})
|
|
99
100
|
|
|
101
|
+
describe('resolveListKeyFromUrl', () => {
|
|
102
|
+
it('resolves a PascalCase list key from its URL segment', () => {
|
|
103
|
+
expect(resolveListKeyFromUrl('blog-post', ['User', 'BlogPost'])).toBe('BlogPost')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('resolves a camelCase list key (e.g. a derived plugin list) from its URL segment', () => {
|
|
107
|
+
// getListKeyFromUrl('oauth-application') would reconstruct 'OauthApplication',
|
|
108
|
+
// which isn't a key in this list — that's the bug this resolver fixes.
|
|
109
|
+
expect(resolveListKeyFromUrl('oauth-application', ['oauthApplication'])).toBe(
|
|
110
|
+
'oauthApplication',
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns undefined for a URL segment matching no list key', () => {
|
|
115
|
+
expect(resolveListKeyFromUrl('does-not-exist', ['User', 'BlogPost'])).toBeUndefined()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('throws when two distinct list keys produce the same URL segment', () => {
|
|
119
|
+
expect(() =>
|
|
120
|
+
resolveListKeyFromUrl('oauth-application', ['oauthApplication', 'OauthApplication']),
|
|
121
|
+
).toThrow(/Ambiguous list URL/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('is derived from getUrlKey, so it cannot drift from how URLs are built', () => {
|
|
125
|
+
const listKeys = ['User', 'AuthUser', 'BlogPost', 'oauthApplication']
|
|
126
|
+
for (const listKey of listKeys) {
|
|
127
|
+
expect(resolveListKeyFromUrl(getUrlKey(listKey), listKeys)).toBe(listKey)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
100
132
|
describe('Round-trip conversions', () => {
|
|
101
133
|
it('should maintain consistency: PascalCase -> kebab-case -> PascalCase', () => {
|
|
102
134
|
const original = 'BlogPost'
|
package/src/lib/case-utils.ts
CHANGED
|
@@ -40,3 +40,29 @@ export function getUrlKey(listKey: string): string {
|
|
|
40
40
|
export function getListKeyFromUrl(urlSegment: string): string {
|
|
41
41
|
return kebabToPascal(urlSegment)
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolves a URL segment back to the list key that produced it, by checking
|
|
46
|
+
* each candidate key against `getUrlKey` — the same helper that builds the
|
|
47
|
+
* URL — rather than reconstructing a key by string transformation the way
|
|
48
|
+
* `getListKeyFromUrl` does. That reconstruction is lossy for any list key
|
|
49
|
+
* that isn't strict PascalCase (e.g. a camelCase key from a derived list),
|
|
50
|
+
* so this is the config-aware resolver route lookups should use instead.
|
|
51
|
+
*
|
|
52
|
+
* Returns `undefined` when no candidate key's URL matches — callers render
|
|
53
|
+
* their usual "not found" state rather than treating this as an error.
|
|
54
|
+
* Throws if more than one candidate key produces the same URL segment: that
|
|
55
|
+
* is a configuration error (two list keys collide once case-folded to a
|
|
56
|
+
* URL) and must be reported, not resolved by silently picking one.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveListKeyFromUrl(urlSegment: string, listKeys: string[]): string | undefined {
|
|
59
|
+
const matches = listKeys.filter((listKey) => getUrlKey(listKey) === urlSegment)
|
|
60
|
+
|
|
61
|
+
if (matches.length > 1) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Ambiguous list URL "${urlSegment}": list keys ${matches.map((key) => `"${key}"`).join(', ')} all resolve to this URL. Rename these lists so their URLs are unique.`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return matches[0]
|
|
68
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
type PrismaP2002Meta = {
|
|
2
|
+
target?: string[]
|
|
3
|
+
driverAdapterError?: {
|
|
4
|
+
cause?: {
|
|
5
|
+
originalMessage?: string
|
|
6
|
+
constraint?: { fields?: unknown[] }
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Postgres quotes an identifier in `constraint.fields` only when it needed
|
|
12
|
+
// quoting (e.g. camelCase columns), so `"tenantId"` and `slug` are both valid
|
|
13
|
+
// entries for the same array.
|
|
14
|
+
function stripIdentifierQuotes(field: string): string {
|
|
15
|
+
const match = field.match(/^"(.*)"$/)
|
|
16
|
+
return match ? match[1] : field
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UniqueConstraintInfo {
|
|
20
|
+
/** Column names covered by the violated constraint, quote-stripped. */
|
|
21
|
+
fields: string[]
|
|
22
|
+
/**
|
|
23
|
+
* The violated constraint's name, when recoverable from the error.
|
|
24
|
+
*
|
|
25
|
+
* Known limit: this is parsed out of Postgres' English-locale error text
|
|
26
|
+
* (`cause.originalMessage`) — the adapter exposes no structured field for
|
|
27
|
+
* it. A server running under a non-English `lc_messages` locale will not
|
|
28
|
+
* match, leaving this `undefined` while `fields` (structured data) still
|
|
29
|
+
* resolves correctly.
|
|
30
|
+
*/
|
|
31
|
+
constraintName?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve which columns — and, where recoverable, which named constraint — a
|
|
36
|
+
* Prisma `P2002` unique-constraint violation hit.
|
|
37
|
+
*
|
|
38
|
+
* Prisma documents `error.meta.target` for this. Under Prisma 7 driver
|
|
39
|
+
* adapters (verified against `@prisma/adapter-pg` and PGlite; see issue #979)
|
|
40
|
+
* that field is `undefined` instead — the same information sits at
|
|
41
|
+
* `error.meta.driverAdapterError.cause`, with the constraint name only
|
|
42
|
+
* recoverable as free text inside `cause.originalMessage`. This normalises
|
|
43
|
+
* both shapes to one stable result so callers never need to reach into that
|
|
44
|
+
* adapter-specific structure themselves.
|
|
45
|
+
*
|
|
46
|
+
* A derived constraint name is truncated by Postgres at its 63-character
|
|
47
|
+
* identifier limit, and one row can violate two unique indexes at once (with
|
|
48
|
+
* Postgres reporting whichever it checked first) — an index an application
|
|
49
|
+
* means to branch on should be named explicitly, and a caller may still need
|
|
50
|
+
* to re-read the colliding row to fully disambiguate.
|
|
51
|
+
*
|
|
52
|
+
* Returns `undefined` when `error` isn't a `P2002` with any recoverable
|
|
53
|
+
* column or constraint information — never throws, since the adapter error
|
|
54
|
+
* shape is untrusted and every level of it may be absent.
|
|
55
|
+
*/
|
|
56
|
+
export function uniqueConstraintOf(error: unknown): UniqueConstraintInfo | undefined {
|
|
57
|
+
if (
|
|
58
|
+
!error ||
|
|
59
|
+
typeof error !== 'object' ||
|
|
60
|
+
!('code' in error) ||
|
|
61
|
+
(error as { code?: unknown }).code !== 'P2002'
|
|
62
|
+
) {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const meta = (error as { meta?: PrismaP2002Meta }).meta
|
|
67
|
+
|
|
68
|
+
if (meta?.target && Array.isArray(meta.target)) {
|
|
69
|
+
return { fields: meta.target }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const cause = meta?.driverAdapterError?.cause
|
|
73
|
+
const rawFields = cause?.constraint?.fields
|
|
74
|
+
const fields = Array.isArray(rawFields)
|
|
75
|
+
? rawFields.filter((f): f is string => typeof f === 'string').map(stripIdentifierQuotes)
|
|
76
|
+
: []
|
|
77
|
+
|
|
78
|
+
const constraintName =
|
|
79
|
+
typeof cause?.originalMessage === 'string'
|
|
80
|
+
? cause.originalMessage.match(/unique constraint "([^"]+)"/)?.[1]
|
|
81
|
+
: undefined
|
|
82
|
+
|
|
83
|
+
if (fields.length === 0 && !constraintName) return undefined
|
|
84
|
+
|
|
85
|
+
return constraintName ? { fields, constraintName } : { fields }
|
|
86
|
+
}
|