@opensaas/stack-core 0.25.0 → 0.27.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 +88 -12
- package/CLAUDE.md +50 -0
- package/dist/access/access-filter.d.ts +17 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +39 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-visibility.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/multi-column-read-write.test.js.map +1 -1
- package/dist/config/label.d.ts +22 -0
- package/dist/config/label.d.ts.map +1 -0
- package/dist/config/label.js +48 -0
- package/dist/config/label.js.map +1 -0
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +19 -5
- 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 +67 -17
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +71 -14
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +6 -0
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.js +133 -7
- package/dist/fields/calendar-day.test.js.map +1 -1
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +7 -3
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +37 -8
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/relationship-options.d.ts +26 -0
- package/dist/query/relationship-options.d.ts.map +1 -0
- package/dist/query/relationship-options.js +43 -0
- package/dist/query/relationship-options.js.map +1 -0
- package/dist/query/relationship-options.test.d.ts +2 -0
- package/dist/query/relationship-options.test.d.ts.map +1 -0
- package/dist/query/relationship-options.test.js +121 -0
- package/dist/query/relationship-options.test.js.map +1 -0
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/schema.js.map +1 -1
- package/package.json +6 -5
- package/src/access/access-filter.ts +54 -0
- package/src/access/index.ts +5 -1
- package/src/config/label.ts +58 -0
- package/src/config/types.ts +20 -13
- package/src/context/apply-defaults.ts +79 -0
- package/src/context/hook-pipeline.ts +11 -0
- package/src/context/index.ts +173 -31
- package/src/context/nested-operations.ts +7 -0
- package/src/context/write-pipeline.ts +1 -2
- package/src/fields/calendar-day.test.ts +158 -7
- package/src/fields/index.ts +39 -11
- package/src/hooks/index.ts +1 -2
- package/src/index.ts +16 -0
- package/src/query/relationship-options.test.ts +157 -0
- package/src/query/relationship-options.ts +70 -0
- package/tests/apply-defaults.test.ts +119 -0
- package/tests/context.test.ts +220 -0
- package/tests/default-value-create.test.ts +299 -0
- package/tests/interactive-transaction.test.ts +444 -0
- package/tests/label.test.ts +135 -0
- package/tests/sudo.test.ts +7 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"password.d.ts","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAChC,aAAa,EAAE,MAAM,EACrB,UAAU,GAAE,MAA4B,GACvC,OAAO,CAAC,MAAM,CAAC,CAMjB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CACnC,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,OAAO,CAAC,CAgBlB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAMvD;AAED;;;;;;;;;;GAUG;AACH,qBAAa,cAAc;IACb,OAAO,CAAC,QAAQ,CAAC,IAAI;
|
|
1
|
+
{"version":3,"file":"password.d.ts","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAChC,aAAa,EAAE,MAAM,EACrB,UAAU,GAAE,MAA4B,GACvC,OAAO,CAAC,MAAM,CAAC,CAMjB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CACnC,aAAa,EAAE,MAAM,EACrB,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,OAAO,CAAC,CAgBlB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAMvD;AAED;;;;;;;;;;GAUG;AACH,qBAAa,cAAc;IACb,OAAO,CAAC,QAAQ,CAAC,IAAI;IAAjC,YAA6B,IAAI,EAAE,MAAM,EAIxC;IAED;;;;;OAKG;IACG,OAAO,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAErD;IAED;;;OAGG;IACH,QAAQ,IAAI,MAAM,CAEjB;IAED;;;OAGG;IACH,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKzC;IAED;;;OAGG;IACH,MAAM,IAAI,MAAM,CAEf;IAED;;;OAGG;IACH,OAAO,IAAI,MAAM,CAEhB;CACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"password.js","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,UAAU,CAAA;AAE7B;;;;GAIG;AACH,MAAM,mBAAmB,GAAG,EAAE,CAAA;AAE9B;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,aAAqB,EACrB,
|
|
1
|
+
{"version":3,"file":"password.js","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,UAAU,CAAA;AAE7B;;;;GAIG;AACH,MAAM,mBAAmB,GAAG,EAAE,CAAA;AAE9B;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,aAAqB,EACrB,UAAU,GAAW,mBAAmB;IAExC,IAAI,OAAO,aAAa,KAAK,QAAQ,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;IAED,OAAO,MAAM,CAAC,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,CAAA;AAC/C,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,aAAqB,EACrB,cAAsB;IAEtB,IAAI,OAAO,aAAa,KAAK,QAAQ,IAAI,OAAO,cAAc,KAAK,QAAQ,EAAE,CAAC;QAC5E,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,IAAI,cAAc,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC9D,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,CAAC;QACH,OAAO,MAAM,MAAM,CAAC,OAAO,CAAC,aAAa,EAAE,cAAc,CAAC,CAAA;IAC5D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,4CAA4C;QAC5C,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAA;QACnD,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa;IAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IAE3C,uEAAuE;IACvE,uCAAuC;IACvC,OAAO,0BAA0B,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;AAC/C,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,OAAO,cAAc;IACI,IAAI;IAAjC,YAA6B,IAAY;oBAAZ,IAAI;QAC/B,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YACtC,MAAM,IAAI,KAAK,CAAC,iDAAiD,CAAC,CAAA;QACpE,CAAC;IACH,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,OAAO,CAAC,aAAqB;QACjC,OAAO,eAAe,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;IAClD,CAAC;IAED;;;OAGG;IACH,QAAQ;QACN,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED;;;OAGG;IACH,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,IAAY;QAC/B,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YAC5C,OAAO,IAAI,CAAC,IAAI,CAAA;QAClB,CAAC;QACD,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED;;;OAGG;IACH,MAAM;QACJ,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED;;;OAGG;IACH,OAAO;QACL,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;CACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAGvB;;GAEG;AACH,MAAM,UAAU,iBAAiB,CAC/B,YAAyC,EACzC,
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAGvB;;GAEG;AACH,MAAM,UAAU,iBAAiB,CAC/B,YAAyC,EACzC,SAAS,GAAwB,QAAQ;IAEzC,MAAM,KAAK,GAAiC,EAAE,CAAA;IAE9C,KAAK,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,CAAC;QACpE,wDAAwD;QACxD,+DAA+D;QAC/D,IACE,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;YACpD,WAAW,CAAC,IAAI,KAAK,cAAc;YACnC,WAAW,CAAC,OAAO,EACnB,CAAC;YACD,SAAQ;QACV,CAAC;QAED,mCAAmC;QACnC,IAAI,WAAW,CAAC,YAAY,EAAE,CAAC;YAC7B,KAAK,CAAC,SAAS,CAAC,GAAG,WAAW,CAAC,YAAY,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;QACnE,CAAC;aAAM,CAAC;YACN,4DAA4D;YAC5D,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAA;QACvC,CAAC;IACH,CAAC;IAED,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACxB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAC7B,IAA6B,EAC7B,YAAyC,EACzC,SAAS,GAAwB,QAAQ;IAEzC,MAAM,MAAM,GAAG,iBAAiB,CAAC,YAAY,EAAE,SAAS,CAAC,CAAA;IAEzD,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;IAErC,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,sDAAsD;IACtD,MAAM,MAAM,GAA2B,EAAE,CAAA;IACzC,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;QACxC,MAAM,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACtC,MAAM,CAAC,SAAS,CAAC,GAAG,KAAK,CAAC,OAAO,CAAA;IACnC,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,CAAA;AACnC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensaas/stack-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Core stack for OpenSaas - schema definition, access control, and runtime utilities",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -57,10 +57,11 @@
|
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@prisma/client": "^7.8.0",
|
|
60
|
-
"@types/node": "^
|
|
61
|
-
"@
|
|
62
|
-
"
|
|
63
|
-
"
|
|
60
|
+
"@types/node": "^26.1.1",
|
|
61
|
+
"@typescript/native": "npm:typescript@^7.0.2",
|
|
62
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
63
|
+
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
64
|
+
"vitest": "^4.1.10"
|
|
64
65
|
},
|
|
65
66
|
"scripts": {
|
|
66
67
|
"build": "tsc",
|
|
@@ -251,3 +251,57 @@ export function mergeIncludeWithAccessControl(
|
|
|
251
251
|
|
|
252
252
|
return merged
|
|
253
253
|
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Remove keys that correspond to `virtual` fields from a Prisma `include`
|
|
257
|
+
* object, recursing into nested relationship includes using the related
|
|
258
|
+
* list's field configs.
|
|
259
|
+
*
|
|
260
|
+
* Virtual fields are computed in JavaScript (via `resolveOutput` in
|
|
261
|
+
* `field-visibility.ts`) and have no database column. Naming one in `include`
|
|
262
|
+
* type-checks — the generated `Include` type lists every field the config
|
|
263
|
+
* declares — but Prisma throws `Unknown field '<name>' for include statement`
|
|
264
|
+
* at runtime. This is applied as the final step on whatever `include` a read
|
|
265
|
+
* op ends up with (fragment-built, access-controlled merge, or sudo
|
|
266
|
+
* passthrough) so a virtual key never reaches the Prisma client, regardless
|
|
267
|
+
* of which path produced it. The virtual value is still computed
|
|
268
|
+
* unconditionally by `filterReadableFields`, so stripping the include key has
|
|
269
|
+
* no effect on whether the value appears in the result (#628).
|
|
270
|
+
*/
|
|
271
|
+
export function stripVirtualFieldsFromInclude(
|
|
272
|
+
include: Record<string, unknown> | undefined,
|
|
273
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
274
|
+
config: OpenSaasConfig,
|
|
275
|
+
): Record<string, unknown> | undefined {
|
|
276
|
+
if (!include) return include
|
|
277
|
+
|
|
278
|
+
const result: Record<string, unknown> = {}
|
|
279
|
+
|
|
280
|
+
for (const [key, value] of Object.entries(include)) {
|
|
281
|
+
const fieldConfig = fieldConfigs[key]
|
|
282
|
+
|
|
283
|
+
// Virtual fields have no database column — drop them from the include.
|
|
284
|
+
if (fieldConfig?.virtual) continue
|
|
285
|
+
|
|
286
|
+
const isDeclaredRelationship =
|
|
287
|
+
fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref
|
|
288
|
+
const entry = asEntryObject(value)
|
|
289
|
+
|
|
290
|
+
if (isDeclaredRelationship && entry?.include) {
|
|
291
|
+
const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
292
|
+
if (relatedConfig) {
|
|
293
|
+
const nestedInclude = stripVirtualFieldsFromInclude(
|
|
294
|
+
entry.include,
|
|
295
|
+
relatedConfig.listConfig.fields,
|
|
296
|
+
config,
|
|
297
|
+
)
|
|
298
|
+
result[key] = { ...(value as Record<string, unknown>), include: nestedInclude }
|
|
299
|
+
continue
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
result[key] = value
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return result
|
|
307
|
+
}
|
package/src/access/index.ts
CHANGED
|
@@ -22,6 +22,10 @@ export {
|
|
|
22
22
|
// Canonical field-level access evaluation (shared by read and write paths).
|
|
23
23
|
export { checkFieldAccess, filterWritableFields } from './field-access.js'
|
|
24
24
|
// Phase 1 — Access Filter (pre-query row/relation scoping).
|
|
25
|
-
export {
|
|
25
|
+
export {
|
|
26
|
+
buildIncludeWithAccessControl,
|
|
27
|
+
mergeIncludeWithAccessControl,
|
|
28
|
+
stripVirtualFieldsFromInclude,
|
|
29
|
+
} from './access-filter.js'
|
|
26
30
|
// Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
|
|
27
31
|
export { filterReadableFields } from './field-visibility.js'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ListConfig } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the field name that represents a list's rows as a single label —
|
|
5
|
+
* the projection half of the label seam. `getItemLabel` reads exactly the
|
|
6
|
+
* field this returns, so the field chosen for projection can never drift
|
|
7
|
+
* from the field used for rendering.
|
|
8
|
+
*
|
|
9
|
+
* Fallback order: configured `ui.labelField` → `name` → `title` → `id`
|
|
10
|
+
* (first field that exists on the list).
|
|
11
|
+
*
|
|
12
|
+
* @throws if `ui.labelField` is set but does not reference a declared,
|
|
13
|
+
* non-relationship field on the list.
|
|
14
|
+
*/
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
16
|
+
export function getLabelFieldName(listConfig: ListConfig<any>): string {
|
|
17
|
+
const configured = listConfig.ui?.labelField
|
|
18
|
+
|
|
19
|
+
if (configured !== undefined) {
|
|
20
|
+
const field = listConfig.fields[configured]
|
|
21
|
+
if (!field) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`ui.labelField "${configured}" does not reference a field declared on this list.`,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
if (field.type === 'relationship') {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`ui.labelField "${configured}" must reference a scalar field, not a relationship.`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
return configured
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if ('name' in listConfig.fields) return 'name'
|
|
35
|
+
if ('title' in listConfig.fields) return 'title'
|
|
36
|
+
return 'id'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the display label for a single row — the render half of the label
|
|
41
|
+
* seam. Reads the field `getLabelFieldName` resolves, falling back to `id`
|
|
42
|
+
* when that field is missing from the row (e.g. stripped by field-level
|
|
43
|
+
* access).
|
|
44
|
+
*/
|
|
45
|
+
export function getItemLabel(
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
47
|
+
listConfig: ListConfig<any>,
|
|
48
|
+
item: Record<string, unknown>,
|
|
49
|
+
): string {
|
|
50
|
+
const fieldName = getLabelFieldName(listConfig)
|
|
51
|
+
const value = Object.prototype.hasOwnProperty.call(item, fieldName) ? item[fieldName] : undefined
|
|
52
|
+
|
|
53
|
+
if (value === undefined || value === null) {
|
|
54
|
+
return String(item.id)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return String(value)
|
|
58
|
+
}
|
package/src/config/types.ts
CHANGED
|
@@ -5,14 +5,7 @@ import type { z } from 'zod'
|
|
|
5
5
|
* Field configuration types
|
|
6
6
|
*/
|
|
7
7
|
export type FieldType =
|
|
8
|
-
| '
|
|
9
|
-
| 'integer'
|
|
10
|
-
| 'checkbox'
|
|
11
|
-
| 'timestamp'
|
|
12
|
-
| 'password'
|
|
13
|
-
| 'select'
|
|
14
|
-
| 'relationship'
|
|
15
|
-
| string // Allow custom field types from third-party packages
|
|
8
|
+
'text' | 'integer' | 'checkbox' | 'timestamp' | 'password' | 'select' | 'relationship' | string // Allow custom field types from third-party packages
|
|
16
9
|
|
|
17
10
|
/**
|
|
18
11
|
* Field-level hook argument types (exported for user annotations)
|
|
@@ -1711,11 +1704,11 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
|
|
|
1711
1704
|
/**
|
|
1712
1705
|
* List-level UI configuration for the admin interface.
|
|
1713
1706
|
*
|
|
1714
|
-
* Mirrors Keystone's `ui` block on a list.
|
|
1715
|
-
*
|
|
1716
|
-
* Keystone concerns (`label`, `
|
|
1717
|
-
* deferred as they cover
|
|
1718
|
-
*
|
|
1707
|
+
* Mirrors Keystone's `ui` block on a list. List-view defaults (column
|
|
1708
|
+
* selection/order and default sort) and the label field are supported
|
|
1709
|
+
* today; other Keystone concerns (`label`, `description`) are intentionally
|
|
1710
|
+
* deferred as they cover navigation text rather than list-view or
|
|
1711
|
+
* row-labelling defaults.
|
|
1719
1712
|
*/
|
|
1720
1713
|
export type ListUIConfig = {
|
|
1721
1714
|
/**
|
|
@@ -1723,6 +1716,20 @@ export type ListUIConfig = {
|
|
|
1723
1716
|
* Keystone's `ui.listView`.
|
|
1724
1717
|
*/
|
|
1725
1718
|
listView?: ListViewUIConfig
|
|
1719
|
+
/**
|
|
1720
|
+
* The field used to represent a row as a single label — in relationship
|
|
1721
|
+
* cells, dropdown options, and page headings. Must reference a declared,
|
|
1722
|
+
* non-relationship field on this list.
|
|
1723
|
+
*
|
|
1724
|
+
* When omitted, resolves via `getLabelFieldName`'s fallback order: `name`
|
|
1725
|
+
* → `title` → `id` (first field that exists on the list).
|
|
1726
|
+
*
|
|
1727
|
+
* @example
|
|
1728
|
+
* ```typescript
|
|
1729
|
+
* ui: { labelField: 'email' }
|
|
1730
|
+
* ```
|
|
1731
|
+
*/
|
|
1732
|
+
labelField?: string
|
|
1726
1733
|
}
|
|
1727
1734
|
|
|
1728
1735
|
/**
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { FieldConfig } from '../config/types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Apply field `defaultValue`s to omitted inputs on CREATE — the runtime half of
|
|
5
|
+
* the resolve-then-validate ordering (Keystone 6 parity, issue #615).
|
|
6
|
+
*
|
|
7
|
+
* A field's `defaultValue` is otherwise only realised as a Prisma `@default(...)`
|
|
8
|
+
* applied by the database at write time, which is AFTER the write pipeline's
|
|
9
|
+
* validation phase has already run. That ordering means a required-with-default
|
|
10
|
+
* field (e.g. `select({ validation: { isRequired: true }, defaultValue: 'X' })`)
|
|
11
|
+
* fails `isRequired` validation on an omitted input even though a default exists.
|
|
12
|
+
*
|
|
13
|
+
* This helper closes that gap: in the resolve phase (after `resolveInput` hooks,
|
|
14
|
+
* before validation) it fills `resolvedData[field]` with the field's
|
|
15
|
+
* `defaultValue` ONLY when the field was OMITTED (value is `undefined`). It is a
|
|
16
|
+
* SINGLE shared mechanism used by both the top-level create path (Hook Pipeline)
|
|
17
|
+
* and the nested-relation create path.
|
|
18
|
+
*
|
|
19
|
+
* Guard rails (each acceptance-criteria-driven):
|
|
20
|
+
* - CREATE only. Update never injects defaults for omitted fields (the caller
|
|
21
|
+
* only invokes this for `operation === 'create'`).
|
|
22
|
+
* - Explicitly-provided values are preserved. A key present in `resolvedData`
|
|
23
|
+
* — INCLUDING an explicit `null` — is left untouched; only `undefined`
|
|
24
|
+
* (omitted) keys are filled.
|
|
25
|
+
* - Virtual, system (`id`/`createdAt`/`updatedAt`) and relationship fields are
|
|
26
|
+
* skipped — they have no scalar `defaultValue` to inject and relationships
|
|
27
|
+
* carry connect/create payloads rather than literal defaults.
|
|
28
|
+
* - The timestamp `{ kind: 'now' }` sentinel is NOT injected: it is not a
|
|
29
|
+
* literal value but a request for the DB-level `@default(now())`, which still
|
|
30
|
+
* applies at write time. Injecting the sentinel object would corrupt the
|
|
31
|
+
* payload. (A concrete `Date` default is a real literal and IS injected.)
|
|
32
|
+
*
|
|
33
|
+
* The function mutates and returns `resolvedData` (consistent with the other
|
|
34
|
+
* resolve-phase helpers that thread `resolvedData` through the pipeline).
|
|
35
|
+
*/
|
|
36
|
+
export function applyCreateDefaults(
|
|
37
|
+
resolvedData: Record<string, unknown>,
|
|
38
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
39
|
+
): Record<string, unknown> {
|
|
40
|
+
for (const [fieldKey, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
41
|
+
// Skip virtual fields — not stored in the database.
|
|
42
|
+
if (fieldConfig.virtual) continue
|
|
43
|
+
|
|
44
|
+
// Skip system fields — always managed by the framework/DB.
|
|
45
|
+
if (fieldKey === 'id' || fieldKey === 'createdAt' || fieldKey === 'updatedAt') continue
|
|
46
|
+
|
|
47
|
+
// Skip relationships — they carry connect/create payloads, not literal defaults.
|
|
48
|
+
if (fieldConfig.type === 'relationship') continue
|
|
49
|
+
|
|
50
|
+
// No declared default → nothing to inject.
|
|
51
|
+
if (!('defaultValue' in fieldConfig) || fieldConfig.defaultValue === undefined) continue
|
|
52
|
+
|
|
53
|
+
// Only fill OMITTED keys. An explicitly-provided value (including explicit
|
|
54
|
+
// `null`) is preserved and must not be overwritten by the default.
|
|
55
|
+
if (resolvedData[fieldKey] !== undefined) continue
|
|
56
|
+
|
|
57
|
+
const defaultValue = fieldConfig.defaultValue
|
|
58
|
+
|
|
59
|
+
// The timestamp `{ kind: 'now' }` sentinel is a DB-level `@default(now())`
|
|
60
|
+
// request, not a literal — leave it for Prisma to apply at write time.
|
|
61
|
+
if (isNowSentinel(defaultValue)) continue
|
|
62
|
+
|
|
63
|
+
resolvedData[fieldKey] = defaultValue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return resolvedData
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Detect the timestamp `{ kind: 'now' }` default sentinel.
|
|
71
|
+
*/
|
|
72
|
+
function isNowSentinel(value: unknown): boolean {
|
|
73
|
+
return (
|
|
74
|
+
typeof value === 'object' &&
|
|
75
|
+
value !== null &&
|
|
76
|
+
'kind' in value &&
|
|
77
|
+
(value as { kind: unknown }).kind === 'now'
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
validateFieldRules,
|
|
9
9
|
ValidationError,
|
|
10
10
|
} from '../hooks/index.js'
|
|
11
|
+
import { applyCreateDefaults } from './apply-defaults.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Hook Pipeline — the single module that runs the transform+validate span of a
|
|
@@ -109,6 +110,16 @@ async function runHookPipeline(args: HookPipelineArgs): Promise<HookPipelineResu
|
|
|
109
110
|
item,
|
|
110
111
|
)
|
|
111
112
|
|
|
113
|
+
// ── Phase 1.75: apply field defaults to omitted inputs (CREATE only) ───────
|
|
114
|
+
// Resolve-then-validate (Keystone parity, #615): a field declaring a
|
|
115
|
+
// `defaultValue` is filled into `resolvedData` here — AFTER resolveInput hooks
|
|
116
|
+
// and BEFORE validation — but only when the field was OMITTED, so a
|
|
117
|
+
// required-with-default field passes `isRequired` instead of failing it.
|
|
118
|
+
// Update is untouched (no default injection on update).
|
|
119
|
+
if (operation === 'create') {
|
|
120
|
+
resolvedData = applyCreateDefaults(resolvedData, listConfig.fields)
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
// ── Phase 2: list-level validate ──────────────────────────────────────────
|
|
113
124
|
await executeValidate(
|
|
114
125
|
listConfig.hooks,
|
package/src/context/index.ts
CHANGED
|
@@ -6,11 +6,13 @@ import {
|
|
|
6
6
|
filterReadableFields,
|
|
7
7
|
buildIncludeWithAccessControl,
|
|
8
8
|
mergeIncludeWithAccessControl,
|
|
9
|
+
stripVirtualFieldsFromInclude,
|
|
9
10
|
} from '../access/index.js'
|
|
10
11
|
import { ValidationError, DatabaseError } from '../hooks/index.js'
|
|
11
12
|
import { getDbKey } from '../lib/case-utils.js'
|
|
12
13
|
import type { PrismaClientLike } from '../access/types.js'
|
|
13
14
|
import { buildInclude, pickFields, isFragment } from '../query/index.js'
|
|
15
|
+
import { getRelationshipOptions } from '../query/relationship-options.js'
|
|
14
16
|
import {
|
|
15
17
|
runWritePipeline,
|
|
16
18
|
createWriteStrategy,
|
|
@@ -22,6 +24,14 @@ export type ServerActionProps =
|
|
|
22
24
|
| { listKey: string; action: 'create'; data: Record<string, unknown> }
|
|
23
25
|
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
|
|
24
26
|
| { listKey: string; action: 'delete'; id: string }
|
|
27
|
+
| {
|
|
28
|
+
listKey: string
|
|
29
|
+
action: 'relationshipOptions'
|
|
30
|
+
field: string
|
|
31
|
+
search?: string
|
|
32
|
+
take?: number
|
|
33
|
+
selectedIds?: string[]
|
|
34
|
+
}
|
|
25
35
|
|
|
26
36
|
/**
|
|
27
37
|
* Tracks which (listName, operation) pairs have already warned about an ignored
|
|
@@ -230,6 +240,87 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
230
240
|
return new Error('An unknown error occurred')
|
|
231
241
|
}
|
|
232
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Database transaction isolation levels.
|
|
245
|
+
*
|
|
246
|
+
* Mirrors Prisma's `TransactionIsolationLevel`. The level passed to
|
|
247
|
+
* {@link StackContext.transaction} is forwarded to the underlying interactive
|
|
248
|
+
* transaction; provider support varies (e.g. `Serializable` is supported by
|
|
249
|
+
* PostgreSQL — required for the concurrency-sensitive capacity-gate pattern).
|
|
250
|
+
*/
|
|
251
|
+
export type TransactionIsolationLevel =
|
|
252
|
+
'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable' | 'Snapshot'
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Options for {@link StackContext.transaction}, forwarded verbatim to the
|
|
256
|
+
* underlying Prisma interactive transaction.
|
|
257
|
+
*/
|
|
258
|
+
export interface TransactionOptions {
|
|
259
|
+
/** Max ms to wait to acquire a transaction from the pool. */
|
|
260
|
+
maxWait?: number
|
|
261
|
+
/** Max ms the interactive transaction may run before timing out. */
|
|
262
|
+
timeout?: number
|
|
263
|
+
/** Isolation level for the transaction (e.g. `'Serializable'`). */
|
|
264
|
+
isolationLevel?: TransactionIsolationLevel
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Minimal shape of a Prisma client that can open an interactive transaction.
|
|
269
|
+
* A Prisma transaction client (the `tx` handed to the callback) intentionally
|
|
270
|
+
* does NOT expose `$transaction`, which is how nested writes detect they are
|
|
271
|
+
* already inside a transaction and join it rather than opening another.
|
|
272
|
+
*/
|
|
273
|
+
interface TransactionCapable<TPrisma> {
|
|
274
|
+
$transaction?: (
|
|
275
|
+
fn: (tx: TPrisma) => Promise<unknown>,
|
|
276
|
+
options?: TransactionOptions,
|
|
277
|
+
) => Promise<unknown>
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The access-controlled context returned by {@link getContext}.
|
|
282
|
+
*
|
|
283
|
+
* Exposes the secured `db` delegate plus the session, raw `prisma`, storage,
|
|
284
|
+
* plugin services, the generic `serverAction` handler, `sudo()` (bypasses access
|
|
285
|
+
* control but still runs hooks), and `transaction()` (interactive, hook-firing
|
|
286
|
+
* transaction). All access-checked operations run their list/field hooks.
|
|
287
|
+
*/
|
|
288
|
+
export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLike> {
|
|
289
|
+
db: AccessControlledDB<TPrisma>
|
|
290
|
+
session: Session | null
|
|
291
|
+
prisma: TPrisma
|
|
292
|
+
storage: StorageUtils
|
|
293
|
+
plugins: Record<string, unknown>
|
|
294
|
+
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
295
|
+
/**
|
|
296
|
+
* Run `fn` inside ONE interactive transaction. The `txContext` handed to `fn`
|
|
297
|
+
* is a full {@link StackContext} whose `db.*` operations are access-checked and
|
|
298
|
+
* hook-firing (identical to this context) but persist against the transaction
|
|
299
|
+
* client, so every write in the callback is atomic — a throw anywhere rolls the
|
|
300
|
+
* whole transaction back.
|
|
301
|
+
*
|
|
302
|
+
* `options` (notably `isolationLevel`) is forwarded to the underlying Prisma
|
|
303
|
+
* transaction. Serialization failures (e.g. Prisma `P2034`) propagate to the
|
|
304
|
+
* caller rather than being swallowed, so the caller can own a retry loop. If
|
|
305
|
+
* the client cannot open an interactive transaction (e.g. a plain mock, or we
|
|
306
|
+
* are already inside a transaction), `fn` runs directly against the current
|
|
307
|
+
* client with identical hook/access semantics.
|
|
308
|
+
*
|
|
309
|
+
* Caveat: plugin runtime services (`txContext.plugins`) stay bound to the
|
|
310
|
+
* top-level (non-transaction) client — they are shared services initialised
|
|
311
|
+
* once per request. Reads through a plugin service therefore won't see this
|
|
312
|
+
* transaction's uncommitted writes, and a plugin service that WRITES would
|
|
313
|
+
* escape the transaction and survive a rollback. Use `txContext.db` (not a
|
|
314
|
+
* plugin service) for writes that must be atomic with the transaction.
|
|
315
|
+
*/
|
|
316
|
+
transaction: <T>(
|
|
317
|
+
fn: (txContext: StackContext<TPrisma>) => Promise<T>,
|
|
318
|
+
options?: TransactionOptions,
|
|
319
|
+
) => Promise<T>
|
|
320
|
+
sudo: () => StackContext<TPrisma>
|
|
321
|
+
_isSudo: boolean
|
|
322
|
+
}
|
|
323
|
+
|
|
233
324
|
/**
|
|
234
325
|
* Create an access-controlled context
|
|
235
326
|
*
|
|
@@ -247,25 +338,10 @@ export function getContext<
|
|
|
247
338
|
session: Session | null,
|
|
248
339
|
storage?: StorageUtils,
|
|
249
340
|
_isSudo: boolean = false,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
storage: StorageUtils
|
|
255
|
-
plugins: Record<string, unknown>
|
|
256
|
-
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
257
|
-
_isSudo: boolean
|
|
258
|
-
sudo: () => {
|
|
259
|
-
db: AccessControlledDB<TPrisma>
|
|
260
|
-
session: Session | null
|
|
261
|
-
prisma: TPrisma
|
|
262
|
-
storage: StorageUtils
|
|
263
|
-
plugins: Record<string, unknown>
|
|
264
|
-
serverAction: (props: ServerActionProps) => Promise<unknown>
|
|
265
|
-
sudo: () => unknown
|
|
266
|
-
_isSudo: boolean
|
|
267
|
-
}
|
|
268
|
-
} {
|
|
341
|
+
// Internal: when rebuilding the context against a transaction client, reuse the
|
|
342
|
+
// already-initialised plugin services rather than re-running plugin runtimes.
|
|
343
|
+
_sharedPlugins?: Record<string, unknown>,
|
|
344
|
+
): StackContext<TPrisma> {
|
|
269
345
|
// Initialize db object - will be populated with access-controlled operations
|
|
270
346
|
// Type is intentionally broad to allow dynamic model access
|
|
271
347
|
const db: Record<string, unknown> = {}
|
|
@@ -298,7 +374,9 @@ export function getContext<
|
|
|
298
374
|
)
|
|
299
375
|
},
|
|
300
376
|
},
|
|
301
|
-
|
|
377
|
+
// Reuse already-initialised plugin services when rebinding to a transaction
|
|
378
|
+
// client, otherwise start empty and populate via plugin runtimes below.
|
|
379
|
+
plugins: _sharedPlugins ?? {},
|
|
302
380
|
_isSudo,
|
|
303
381
|
_resolveOutputCounter: { depth: 0 },
|
|
304
382
|
}
|
|
@@ -306,16 +384,20 @@ export function getContext<
|
|
|
306
384
|
// Create access-controlled operations for each list, populating `db` in place.
|
|
307
385
|
populateDbDelegate(db, config, prisma, context)
|
|
308
386
|
|
|
309
|
-
// Execute plugin runtime functions and populate context.plugins
|
|
387
|
+
// Execute plugin runtime functions and populate context.plugins.
|
|
388
|
+
// Skipped when reusing shared plugins (transaction rebind) so runtimes — and
|
|
389
|
+
// any side effects they carry — run exactly once per top-level context.
|
|
310
390
|
// Use _plugins (sorted by dependencies) if available, otherwise fall back to plugins array
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
391
|
+
if (!_sharedPlugins) {
|
|
392
|
+
const pluginsToExecute = config._plugins || config.plugins || []
|
|
393
|
+
for (const plugin of pluginsToExecute) {
|
|
394
|
+
if (plugin.runtime) {
|
|
395
|
+
try {
|
|
396
|
+
context.plugins[plugin.name] = plugin.runtime(context)
|
|
397
|
+
} catch (error) {
|
|
398
|
+
console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
|
|
399
|
+
// Continue with other plugins even if one fails
|
|
400
|
+
}
|
|
319
401
|
}
|
|
320
402
|
}
|
|
321
403
|
}
|
|
@@ -345,6 +427,26 @@ export function getContext<
|
|
|
345
427
|
}
|
|
346
428
|
|
|
347
429
|
try {
|
|
430
|
+
if (props.action === 'relationshipOptions') {
|
|
431
|
+
const fieldConfig = listConfig.fields[props.field] as
|
|
432
|
+
{ type?: string; ref?: string } | undefined
|
|
433
|
+
if (!fieldConfig || fieldConfig.type !== 'relationship' || !fieldConfig.ref) {
|
|
434
|
+
return {
|
|
435
|
+
success: false,
|
|
436
|
+
error: `Field "${props.field}" on list "${props.listKey}" is not a relationship field`,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const relatedListKey = fieldConfig.ref.split('.')[0]
|
|
441
|
+
const options = await getRelationshipOptions(context, config, relatedListKey, {
|
|
442
|
+
search: props.search,
|
|
443
|
+
take: props.take,
|
|
444
|
+
selectedIds: props.selectedIds,
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
return { success: true, data: options }
|
|
448
|
+
}
|
|
449
|
+
|
|
348
450
|
let result: unknown = null
|
|
349
451
|
|
|
350
452
|
if (props.action === 'create') {
|
|
@@ -411,11 +513,35 @@ export function getContext<
|
|
|
411
513
|
|
|
412
514
|
// Sudo function - creates a new context that bypasses access control
|
|
413
515
|
// but still executes all hooks and validation
|
|
414
|
-
function sudo() {
|
|
516
|
+
function sudo(): StackContext<TPrisma> {
|
|
415
517
|
return getContext(config, prisma, session, context.storage, true)
|
|
416
518
|
}
|
|
417
519
|
|
|
418
|
-
|
|
520
|
+
// Interactive, hook-firing transaction (#614). Rebinds the access-controlled
|
|
521
|
+
// context to the transaction client so every `txContext.db.*` write runs its
|
|
522
|
+
// access checks + hooks but persists inside ONE transaction (atomic). The
|
|
523
|
+
// transaction `options` (e.g. `isolationLevel`) pass through to Prisma, and a
|
|
524
|
+
// serialization failure thrown inside the callback propagates to the caller
|
|
525
|
+
// for retry (it is never converted to a silent `null`).
|
|
526
|
+
function transaction<T>(
|
|
527
|
+
fn: (txContext: StackContext<TPrisma>) => Promise<T>,
|
|
528
|
+
options?: TransactionOptions,
|
|
529
|
+
): Promise<T> {
|
|
530
|
+
const client = prisma as unknown as TransactionCapable<TPrisma>
|
|
531
|
+
if (typeof client.$transaction !== 'function') {
|
|
532
|
+
// No interactive transaction available — either a plain client/mock or we
|
|
533
|
+
// are already inside a transaction (a Prisma tx client exposes no
|
|
534
|
+
// `$transaction`). Run directly: hook/access semantics are identical and
|
|
535
|
+
// atomicity is provided by any enclosing transaction.
|
|
536
|
+
return fn(returned)
|
|
537
|
+
}
|
|
538
|
+
return client.$transaction(
|
|
539
|
+
(tx) => fn(getContext(config, tx, session, context.storage, _isSudo, context.plugins)),
|
|
540
|
+
options,
|
|
541
|
+
) as Promise<T>
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const returned: StackContext<TPrisma> = {
|
|
419
545
|
db: db as AccessControlledDB<TPrisma>,
|
|
420
546
|
session,
|
|
421
547
|
prisma,
|
|
@@ -423,8 +549,10 @@ export function getContext<
|
|
|
423
549
|
plugins: context.plugins,
|
|
424
550
|
serverAction,
|
|
425
551
|
sudo,
|
|
552
|
+
transaction,
|
|
426
553
|
_isSudo,
|
|
427
554
|
}
|
|
555
|
+
return returned
|
|
428
556
|
}
|
|
429
557
|
|
|
430
558
|
/**
|
|
@@ -586,6 +714,13 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
586
714
|
: accessControlledInclude
|
|
587
715
|
}
|
|
588
716
|
|
|
717
|
+
// Virtual fields have no database column. Whichever path produced
|
|
718
|
+
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
719
|
+
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
720
|
+
// (#628). The virtual value is still computed unconditionally below by
|
|
721
|
+
// `filterReadableFields`, independent of what was requested here.
|
|
722
|
+
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
723
|
+
|
|
589
724
|
// Execute query with optimized includes
|
|
590
725
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
591
726
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -709,6 +844,13 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
709
844
|
: accessControlledInclude
|
|
710
845
|
}
|
|
711
846
|
|
|
847
|
+
// Virtual fields have no database column. Whichever path produced
|
|
848
|
+
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
849
|
+
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
850
|
+
// (#628). The virtual value is still computed unconditionally below by
|
|
851
|
+
// `filterReadableFields`, independent of what was requested here.
|
|
852
|
+
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
853
|
+
|
|
712
854
|
// Execute query with optimized includes
|
|
713
855
|
// Access Prisma model dynamically - required because model names are generated at runtime
|
|
714
856
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
ValidationError,
|
|
16
16
|
} from '../hooks/index.js'
|
|
17
17
|
import { getDbKey } from '../lib/case-utils.js'
|
|
18
|
+
import { applyCreateDefaults } from './apply-defaults.js'
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Nested writes (#569 / ADR-0010).
|
|
@@ -273,6 +274,12 @@ async function processNestedCreate(
|
|
|
273
274
|
relatedListName,
|
|
274
275
|
)
|
|
275
276
|
|
|
277
|
+
// 3.5 Apply field defaults to omitted inputs (resolve-then-validate, #615).
|
|
278
|
+
// Mirrors the top-level Hook Pipeline so a nested required-with-default
|
|
279
|
+
// field resolves to its default before validation instead of failing
|
|
280
|
+
// `isRequired`. Create-only; explicit values (incl. null) are preserved.
|
|
281
|
+
resolvedData = applyCreateDefaults(resolvedData, relatedListConfig.fields)
|
|
282
|
+
|
|
276
283
|
// 4. Execute validate hook
|
|
277
284
|
await executeValidate(relatedListConfig.hooks, {
|
|
278
285
|
listKey: relatedListName,
|
|
@@ -53,8 +53,7 @@ export type WriteOperation = 'create' | 'update' | 'delete'
|
|
|
53
53
|
* any input phases, before-hooks, or the DB call.
|
|
54
54
|
*/
|
|
55
55
|
export type TargetResolution =
|
|
56
|
-
|
|
57
|
-
| { status: 'denied' }
|
|
56
|
+
{ status: 'ok'; originalItem: Record<string, unknown> | undefined } | { status: 'denied' }
|
|
58
57
|
|
|
59
58
|
/**
|
|
60
59
|
* Minimal dynamic Prisma model surface used by the write pipeline. Model names
|