@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.
Files changed (85) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +88 -12
  3. package/CLAUDE.md +50 -0
  4. package/dist/access/access-filter.d.ts +17 -0
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +39 -0
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/field-visibility.js.map +1 -1
  9. package/dist/access/index.d.ts +1 -1
  10. package/dist/access/index.d.ts.map +1 -1
  11. package/dist/access/index.js +1 -1
  12. package/dist/access/index.js.map +1 -1
  13. package/dist/access/multi-column-read-write.test.js.map +1 -1
  14. package/dist/config/label.d.ts +22 -0
  15. package/dist/config/label.d.ts.map +1 -0
  16. package/dist/config/label.js +48 -0
  17. package/dist/config/label.js.map +1 -0
  18. package/dist/config/plugin-engine.js.map +1 -1
  19. package/dist/config/types.d.ts +19 -5
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/context/apply-defaults.d.ts +36 -0
  22. package/dist/context/apply-defaults.d.ts.map +1 -0
  23. package/dist/context/apply-defaults.js +70 -0
  24. package/dist/context/apply-defaults.js.map +1 -0
  25. package/dist/context/hook-pipeline.d.ts.map +1 -1
  26. package/dist/context/hook-pipeline.js +10 -0
  27. package/dist/context/hook-pipeline.js.map +1 -1
  28. package/dist/context/index.d.ts +67 -17
  29. package/dist/context/index.d.ts.map +1 -1
  30. package/dist/context/index.js +71 -14
  31. package/dist/context/index.js.map +1 -1
  32. package/dist/context/nested-operations.d.ts.map +1 -1
  33. package/dist/context/nested-operations.js +6 -0
  34. package/dist/context/nested-operations.js.map +1 -1
  35. package/dist/context/transaction-boundary.d.ts.map +1 -1
  36. package/dist/context/write-pipeline.d.ts.map +1 -1
  37. package/dist/context/write-pipeline.js.map +1 -1
  38. package/dist/fields/calendar-day.test.js +133 -7
  39. package/dist/fields/calendar-day.test.js.map +1 -1
  40. package/dist/fields/format-prisma-default.js.map +1 -1
  41. package/dist/fields/index.d.ts +7 -3
  42. package/dist/fields/index.d.ts.map +1 -1
  43. package/dist/fields/index.js +37 -8
  44. package/dist/fields/index.js.map +1 -1
  45. package/dist/hooks/index.d.ts.map +1 -1
  46. package/dist/hooks/index.js.map +1 -1
  47. package/dist/index.d.ts +4 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +8 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/query/index.d.ts.map +1 -1
  52. package/dist/query/relationship-options.d.ts +26 -0
  53. package/dist/query/relationship-options.d.ts.map +1 -0
  54. package/dist/query/relationship-options.js +43 -0
  55. package/dist/query/relationship-options.js.map +1 -0
  56. package/dist/query/relationship-options.test.d.ts +2 -0
  57. package/dist/query/relationship-options.test.d.ts.map +1 -0
  58. package/dist/query/relationship-options.test.js +121 -0
  59. package/dist/query/relationship-options.test.js.map +1 -0
  60. package/dist/utils/password.d.ts.map +1 -1
  61. package/dist/utils/password.js.map +1 -1
  62. package/dist/validation/schema.js.map +1 -1
  63. package/package.json +6 -5
  64. package/src/access/access-filter.ts +54 -0
  65. package/src/access/index.ts +5 -1
  66. package/src/config/label.ts +58 -0
  67. package/src/config/types.ts +20 -13
  68. package/src/context/apply-defaults.ts +79 -0
  69. package/src/context/hook-pipeline.ts +11 -0
  70. package/src/context/index.ts +173 -31
  71. package/src/context/nested-operations.ts +7 -0
  72. package/src/context/write-pipeline.ts +1 -2
  73. package/src/fields/calendar-day.test.ts +158 -7
  74. package/src/fields/index.ts +39 -11
  75. package/src/hooks/index.ts +1 -2
  76. package/src/index.ts +16 -0
  77. package/src/query/relationship-options.test.ts +157 -0
  78. package/src/query/relationship-options.ts +70 -0
  79. package/tests/apply-defaults.test.ts +119 -0
  80. package/tests/context.test.ts +220 -0
  81. package/tests/default-value-create.test.ts +299 -0
  82. package/tests/interactive-transaction.test.ts +444 -0
  83. package/tests/label.test.ts +135 -0
  84. package/tests/sudo.test.ts +7 -3
  85. 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;gBAAJ,IAAI,EAAE,MAAM;IAMzC;;;;;OAKG;IACG,OAAO,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAItD;;;OAGG;IACH,QAAQ,IAAI,MAAM;IAIlB;;;OAGG;IACH,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAO1C;;;OAGG;IACH,MAAM,IAAI,MAAM;IAIhB;;;OAGG;IACH,OAAO,IAAI,MAAM;CAGlB"}
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,aAAqB,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;IAA7B,YAA6B,IAAY;QAAZ,SAAI,GAAJ,IAAI,CAAQ;QACvC,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
+ {"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,YAAiC,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,YAAiC,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"}
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.25.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": "^25.9.1",
61
- "@vitest/coverage-v8": "^4.0.18",
62
- "typescript": "^5.9.3",
63
- "vitest": "^4.1.0"
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
+ }
@@ -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 { buildIncludeWithAccessControl, mergeIncludeWithAccessControl } from './access-filter.js'
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
+ }
@@ -5,14 +5,7 @@ import type { z } from 'zod'
5
5
  * Field configuration types
6
6
  */
7
7
  export type FieldType =
8
- | 'text'
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. Only the list-view defaults
1715
- * (column selection/order and default sort) are supported today; other
1716
- * Keystone concerns (`label`, `labelField`, `description`) are intentionally
1717
- * deferred as they cover different concerns (navigation text and
1718
- * relationship-picker labels rather than list-view defaults).
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,
@@ -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
- db: AccessControlledDB<TPrisma>
252
- session: Session | null
253
- prisma: TPrisma
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
- plugins: {}, // Will be populated with plugin runtime services
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
- const pluginsToExecute = config._plugins || config.plugins || []
312
- for (const plugin of pluginsToExecute) {
313
- if (plugin.runtime) {
314
- try {
315
- context.plugins[plugin.name] = plugin.runtime(context)
316
- } catch (error) {
317
- console.error(`Error executing runtime for plugin "${plugin.name}":`, error)
318
- // Continue with other plugins even if one fails
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
- return {
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
- | { status: 'ok'; originalItem: Record<string, unknown> | undefined }
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