@opensaas/stack-core 0.37.0 → 0.39.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 (226) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +191 -0
  3. package/CLAUDE.md +42 -3
  4. package/dist/access/access-filter.d.ts +70 -113
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +183 -210
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +148 -188
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/declared-dependencies.d.ts +66 -26
  11. package/dist/access/declared-dependencies.d.ts.map +1 -1
  12. package/dist/access/declared-dependencies.js +62 -17
  13. package/dist/access/declared-dependencies.js.map +1 -1
  14. package/dist/access/declared-dependencies.test.d.ts +2 -0
  15. package/dist/access/declared-dependencies.test.d.ts.map +1 -0
  16. package/dist/access/declared-dependencies.test.js +226 -0
  17. package/dist/access/declared-dependencies.test.js.map +1 -0
  18. package/dist/access/depth-limits.d.ts +8 -7
  19. package/dist/access/depth-limits.d.ts.map +1 -1
  20. package/dist/access/depth-limits.js +8 -7
  21. package/dist/access/depth-limits.js.map +1 -1
  22. package/dist/access/engine.d.ts +0 -20
  23. package/dist/access/engine.d.ts.map +1 -1
  24. package/dist/access/engine.js +0 -25
  25. package/dist/access/engine.js.map +1 -1
  26. package/dist/access/errors.d.ts +61 -8
  27. package/dist/access/errors.d.ts.map +1 -1
  28. package/dist/access/errors.js +91 -12
  29. package/dist/access/errors.js.map +1 -1
  30. package/dist/access/field-access.d.ts +39 -1
  31. package/dist/access/field-access.d.ts.map +1 -1
  32. package/dist/access/field-access.js +108 -42
  33. package/dist/access/field-access.js.map +1 -1
  34. package/dist/access/field-access.test.js +229 -2
  35. package/dist/access/field-access.test.js.map +1 -1
  36. package/dist/access/field-transforms.d.ts +0 -27
  37. package/dist/access/field-transforms.d.ts.map +1 -1
  38. package/dist/access/field-visibility.d.ts +2 -5
  39. package/dist/access/field-visibility.d.ts.map +1 -1
  40. package/dist/access/field-visibility.js +89 -41
  41. package/dist/access/field-visibility.js.map +1 -1
  42. package/dist/access/index.d.ts +6 -3
  43. package/dist/access/index.d.ts.map +1 -1
  44. package/dist/access/index.js +12 -2
  45. package/dist/access/index.js.map +1 -1
  46. package/dist/access/query-validation.d.ts +121 -0
  47. package/dist/access/query-validation.d.ts.map +1 -0
  48. package/dist/access/query-validation.js +292 -0
  49. package/dist/access/query-validation.js.map +1 -0
  50. package/dist/access/relationship-count.d.ts +1 -1
  51. package/dist/access/relationship-count.d.ts.map +1 -1
  52. package/dist/access/relationship-count.js +2 -10
  53. package/dist/access/relationship-count.js.map +1 -1
  54. package/dist/access/relationship-label-filter.d.ts +23 -23
  55. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  56. package/dist/access/relationship-label-filter.js +5 -76
  57. package/dist/access/relationship-label-filter.js.map +1 -1
  58. package/dist/access/relationship-label-filter.test.js +19 -56
  59. package/dist/access/relationship-label-filter.test.js.map +1 -1
  60. package/dist/access/transaction-registry.d.ts +45 -0
  61. package/dist/access/transaction-registry.d.ts.map +1 -0
  62. package/dist/access/transaction-registry.js +37 -0
  63. package/dist/access/transaction-registry.js.map +1 -0
  64. package/dist/access/types.d.ts +55 -59
  65. package/dist/access/types.d.ts.map +1 -1
  66. package/dist/config/index.d.ts +9 -7
  67. package/dist/config/index.d.ts.map +1 -1
  68. package/dist/config/index.js +10 -18
  69. package/dist/config/index.js.map +1 -1
  70. package/dist/config/label.d.ts +6 -13
  71. package/dist/config/label.d.ts.map +1 -1
  72. package/dist/config/label.js +6 -13
  73. package/dist/config/label.js.map +1 -1
  74. package/dist/config/nav-count.d.ts +11 -19
  75. package/dist/config/nav-count.d.ts.map +1 -1
  76. package/dist/config/nav-count.js +11 -19
  77. package/dist/config/nav-count.js.map +1 -1
  78. package/dist/config/plugin-engine.d.ts +0 -10
  79. package/dist/config/plugin-engine.d.ts.map +1 -1
  80. package/dist/config/plugin-engine.js +1 -37
  81. package/dist/config/plugin-engine.js.map +1 -1
  82. package/dist/config/types.d.ts +94 -109
  83. package/dist/config/types.d.ts.map +1 -1
  84. package/dist/context/apply-defaults.d.ts +8 -31
  85. package/dist/context/apply-defaults.d.ts.map +1 -1
  86. package/dist/context/apply-defaults.js +12 -41
  87. package/dist/context/apply-defaults.js.map +1 -1
  88. package/dist/context/hook-pipeline.d.ts +4 -29
  89. package/dist/context/hook-pipeline.d.ts.map +1 -1
  90. package/dist/context/hook-pipeline.js +8 -41
  91. package/dist/context/hook-pipeline.js.map +1 -1
  92. package/dist/context/index.d.ts +4 -15
  93. package/dist/context/index.d.ts.map +1 -1
  94. package/dist/context/index.js +223 -215
  95. package/dist/context/index.js.map +1 -1
  96. package/dist/context/nested-operations.d.ts +7 -26
  97. package/dist/context/nested-operations.d.ts.map +1 -1
  98. package/dist/context/nested-operations.js +73 -171
  99. package/dist/context/nested-operations.js.map +1 -1
  100. package/dist/context/transaction-boundary.d.ts +36 -47
  101. package/dist/context/transaction-boundary.d.ts.map +1 -1
  102. package/dist/context/transaction-boundary.js +92 -79
  103. package/dist/context/transaction-boundary.js.map +1 -1
  104. package/dist/context/write-pipeline.d.ts +25 -64
  105. package/dist/context/write-pipeline.d.ts.map +1 -1
  106. package/dist/context/write-pipeline.js +81 -111
  107. package/dist/context/write-pipeline.js.map +1 -1
  108. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  109. package/dist/fields/format-prisma-default.js +4 -7
  110. package/dist/fields/format-prisma-default.js.map +1 -1
  111. package/dist/fields/index.d.ts +17 -2
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +136 -147
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/filter/collect.d.ts +26 -3
  116. package/dist/filter/collect.d.ts.map +1 -1
  117. package/dist/filter/collect.js +30 -12
  118. package/dist/filter/collect.js.map +1 -1
  119. package/dist/filter/filter.test.js +98 -15
  120. package/dist/filter/filter.test.js.map +1 -1
  121. package/dist/filter/index.d.ts +1 -0
  122. package/dist/filter/index.d.ts.map +1 -1
  123. package/dist/filter/index.js.map +1 -1
  124. package/dist/filter/map.d.ts.map +1 -1
  125. package/dist/filter/map.js +0 -5
  126. package/dist/filter/map.js.map +1 -1
  127. package/dist/filter/parse.d.ts.map +1 -1
  128. package/dist/filter/parse.js +2 -9
  129. package/dist/filter/parse.js.map +1 -1
  130. package/dist/filter/serialize.d.ts.map +1 -1
  131. package/dist/filter/serialize.js +2 -4
  132. package/dist/filter/serialize.js.map +1 -1
  133. package/dist/hooks/index.d.ts +4 -42
  134. package/dist/hooks/index.d.ts.map +1 -1
  135. package/dist/hooks/index.js +5 -59
  136. package/dist/hooks/index.js.map +1 -1
  137. package/dist/index.d.ts +3 -1
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +20 -4
  140. package/dist/index.js.map +1 -1
  141. package/dist/internal.d.ts +1 -0
  142. package/dist/internal.d.ts.map +1 -1
  143. package/dist/internal.js +4 -0
  144. package/dist/internal.js.map +1 -1
  145. package/dist/lib/case-utils.d.ts +0 -32
  146. package/dist/lib/case-utils.d.ts.map +1 -1
  147. package/dist/lib/case-utils.js +0 -32
  148. package/dist/lib/case-utils.js.map +1 -1
  149. package/dist/mcp/handler.d.ts +0 -4
  150. package/dist/mcp/handler.d.ts.map +1 -1
  151. package/dist/mcp/handler.js +12 -69
  152. package/dist/mcp/handler.js.map +1 -1
  153. package/dist/query/index.d.ts +55 -227
  154. package/dist/query/index.d.ts.map +1 -1
  155. package/dist/query/index.js +41 -117
  156. package/dist/query/index.js.map +1 -1
  157. package/dist/query/relationship-options.d.ts +1 -1
  158. package/dist/query/relationship-options.js +1 -1
  159. package/dist/utils/password.d.ts +0 -68
  160. package/dist/utils/password.d.ts.map +1 -1
  161. package/dist/utils/password.js +7 -76
  162. package/dist/utils/password.js.map +1 -1
  163. package/dist/validation/field-config.d.ts.map +1 -1
  164. package/dist/validation/field-config.js +0 -6
  165. package/dist/validation/field-config.js.map +1 -1
  166. package/dist/validation/schema.d.ts +0 -7
  167. package/dist/validation/schema.d.ts.map +1 -1
  168. package/dist/validation/schema.js +0 -10
  169. package/dist/validation/schema.js.map +1 -1
  170. package/package.json +1 -1
  171. package/src/access/access-filter.test.ts +205 -275
  172. package/src/access/access-filter.ts +236 -272
  173. package/src/access/declared-dependencies.test.ts +277 -0
  174. package/src/access/declared-dependencies.ts +117 -37
  175. package/src/access/depth-limits.ts +8 -7
  176. package/src/access/engine.ts +0 -25
  177. package/src/access/errors.ts +97 -12
  178. package/src/access/field-access.test.ts +312 -2
  179. package/src/access/field-access.ts +118 -42
  180. package/src/access/field-transforms.ts +8 -39
  181. package/src/access/field-visibility.ts +99 -45
  182. package/src/access/index.ts +17 -5
  183. package/src/access/query-validation.ts +359 -0
  184. package/src/access/relationship-count.ts +3 -11
  185. package/src/access/relationship-label-filter.test.ts +19 -68
  186. package/src/access/relationship-label-filter.ts +27 -104
  187. package/src/access/transaction-registry.ts +58 -0
  188. package/src/access/types.ts +83 -86
  189. package/src/config/index.ts +10 -22
  190. package/src/config/label.ts +6 -13
  191. package/src/config/nav-count.ts +11 -19
  192. package/src/config/plugin-engine.ts +1 -37
  193. package/src/config/types.ts +95 -110
  194. package/src/context/apply-defaults.ts +12 -44
  195. package/src/context/hook-pipeline.ts +12 -67
  196. package/src/context/index.ts +311 -276
  197. package/src/context/nested-operations.ts +94 -226
  198. package/src/context/transaction-boundary.ts +113 -109
  199. package/src/context/write-pipeline.ts +99 -155
  200. package/src/fields/format-prisma-default.ts +4 -7
  201. package/src/fields/index.ts +155 -147
  202. package/src/filter/collect.ts +48 -12
  203. package/src/filter/filter.test.ts +153 -15
  204. package/src/filter/index.ts +1 -0
  205. package/src/filter/map.ts +0 -5
  206. package/src/filter/parse.ts +2 -9
  207. package/src/filter/serialize.ts +2 -4
  208. package/src/hooks/index.ts +5 -59
  209. package/src/index.ts +23 -4
  210. package/src/internal.ts +5 -0
  211. package/src/lib/case-utils.ts +0 -32
  212. package/src/mcp/handler.ts +13 -71
  213. package/src/query/index.ts +82 -235
  214. package/src/query/relationship-options.ts +1 -1
  215. package/src/utils/password.ts +7 -76
  216. package/src/validation/field-config.ts +0 -6
  217. package/src/validation/schema.ts +0 -10
  218. package/tests/access-relationships.test.ts +18 -16
  219. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  220. package/tests/context.test.ts +727 -1
  221. package/tests/field-types.test.ts +222 -0
  222. package/tests/mcp-handler.test.ts +34 -0
  223. package/tests/needs-declared-dependencies.test.ts +7 -4
  224. package/tests/resolve-chain.test.ts +11 -11
  225. package/tests/transaction-boundary-outermost.test.ts +530 -0
  226. package/tsconfig.tsbuildinfo +1 -1
@@ -1,4 +1,4 @@
1
1
 
2
- > @opensaas/stack-core@0.37.0 build /home/runner/work/stack/stack/packages/core
2
+ > @opensaas/stack-core@0.39.0 build /home/runner/work/stack/stack/packages/core
3
3
  > tsc
4
4
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,196 @@
1
1
  # @opensaas/stack-core
2
2
 
3
+ ## 0.39.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#926](https://github.com/OpenSaasAU/stack/pull/926) [`5e546b0`](https://github.com/OpenSaasAU/stack/commit/5e546b0fe3542ba41fc77e0a4628acc96eec13ea) Thanks [@borisno2](https://github.com/borisno2)! - Add a first-class `bigInt()` field type for 64-bit integers (e.g. a millisecond epoch) that overflow `integer()`'s 32-bit `Int` — Prisma `BigInt`, TypeScript `bigint`, with an admin UI component, filtering, and MCP support.
8
+
9
+ ```typescript
10
+ import { bigInt } from '@opensaas/stack-core/fields'
11
+
12
+ fields: {
13
+ occurredAtMs: bigInt({ validation: { isRequired: true } }),
14
+ }
15
+
16
+ await context.db.event.create({
17
+ data: { occurredAtMs: 9007199254740993n }, // bigint, number, or numeric string
18
+ })
19
+ ```
20
+
21
+ Create/update accept `bigint`, an integer `number`, or a numeric `string`, and always coerce to `bigint`. A `number` above `Number.MAX_SAFE_INTEGER` is rejected rather than silently losing precision. `bigint` isn't JSON-serialisable, so an MCP CRUD tool renders the value as a decimal string instead of throwing, and the admin UI's server→client boundary (list table, item form, relationship table) now round-trips a `bigint` value correctly rather than throwing during render. The migration introspector maps Prisma `BigInt` columns to `bigInt()` instead of the previous lossy `text()` fallback.
22
+
23
+ - [#919](https://github.com/OpenSaasAU/stack/pull/919) [`cbb03fc`](https://github.com/OpenSaasAU/stack/commit/cbb03fc26047869d23513fbb156c6194d9be389b) Thanks [@borisno2](https://github.com/borisno2)! - Fix a fail-open bug where a field-level access rule returning a Prisma filter (instead of a boolean) silently granted the field full access. `checkFieldAccess` now throws `InvalidFieldAccessResultError` for any non-boolean result instead of defaulting to allow.
24
+
25
+ This narrows `FieldAccessControl`'s return type from `boolean | PrismaFilter | Promise<...>` to `boolean | Promise<boolean>` — field-level access was already documented (ADR-0001) to be boolean-only; the type had drifted from that. If a field rule returned a filter, it will now fail to compile (or throw at runtime for untyped/JS configs) instead of silently granting access. Evaluate the condition yourself and return a boolean instead, e.g.:
26
+
27
+ ```ts
28
+ // Before (silently granted full access on read/write)
29
+ someField: text({
30
+ access: {
31
+ update: ({ session }) => ({ ownerId: { equals: session?.userId } }),
32
+ },
33
+ })
34
+
35
+ // After
36
+ someField: text({
37
+ access: {
38
+ update: ({ session, item }) => !!session && session.userId === item?.ownerId,
39
+ },
40
+ })
41
+ ```
42
+
43
+ See `docs/adr/0030-field-level-access-fails-closed-on-a-non-boolean-result.md` for the full reasoning.
44
+
45
+ - [#925](https://github.com/OpenSaasAU/stack/pull/925) [`6f9a64d`](https://github.com/OpenSaasAU/stack/commit/6f9a64d2f25212e91181adc2b67add326a540f6a) Thanks [@borisno2](https://github.com/borisno2)! - Fix a field-level `read` gate withholding a field's VALUE but leaving its PREDICATE unconstrained: a read-denied field could still be named in a `where`/`orderBy`, letting its value (or relative order) be recovered by probing — `count()` is the cleanest instrument, since it answers a predicate while returning no rows at all. `findMany`/`count` now reject a `where`/`orderBy` key naming a field the session cannot read (including nested inside `AND`/`OR`/`NOT`), throwing instead of returning a silently narrowed or empty result. A `read` rule that depends on the fetched row (`item`) cannot be evaluated before the query runs and now resolves to a denial rather than being skipped — see `docs/adr/0031-a-predicate-cannot-name-a-field-the-session-cannot-read.md`. `sudo` is unaffected.
46
+
47
+ This was independently reachable through the admin UI's own list view: `collectFilterSpecs`, `buildListFilterWhere`, and `collectFilterSuggestions` (`@opensaas/stack-core`) now take a required `{ session, context }` argument and return a `Promise`, excluding a read-denied field from the collected Filter specs so the UI never suggests, autocompletes, or submits a filter the engine is going to reject — a `field:value` token for such a field now degrades to free text instead. The list view's sort validation (`@opensaas/stack-ui`) excludes the same fields from what a `?sort=` URL param may activate.
48
+
49
+ ```ts
50
+ // Before
51
+ const specs = collectFilterSpecs(listConfig, listKey, config)
52
+ const where = buildListFilterWhere(query, listConfig, listKey, config)
53
+ const suggestions = collectFilterSuggestions(listConfig, listKey, config)
54
+
55
+ // After — pass the session/context the field's `read` access is checked against
56
+ const specs = await collectFilterSpecs(listConfig, listKey, config, { session, context })
57
+ const where = await buildListFilterWhere(query, listConfig, listKey, config, { session, context })
58
+ const suggestions = await collectFilterSuggestions(listConfig, listKey, config, {
59
+ session,
60
+ context,
61
+ })
62
+ ```
63
+
64
+ - [#934](https://github.com/OpenSaasAU/stack/pull/934) [`9a399d6`](https://github.com/OpenSaasAU/stack/commit/9a399d68e4d3f384d4cef5ccd5fc8ec6802a40a5) Thanks [@list({](https://github.com/list({)! - `afterTransaction` now fires when the OUTERMOST transaction a write participates in settles, and can report `status: 'rolled-back'` where it always reported `'committed'` before (ADR-0028, fixes [#899](https://github.com/OpenSaasAU/stack/issues/899)).
65
+
66
+ A write that joins a transaction it did not open — inside `context.transaction()`, or a hook's own `context.db` write — used to fire its `afterTransaction` bracket optimistically as soon as its own write returned, even though the enclosing transaction was still open and could still roll back. It now defers that bracket until the transaction owner (`context.transaction()`, or the Write Pipeline when it opened the transaction) observes the real commit/rollback, then reports the outcome as a conjunction: `committed` if and only if the write itself succeeded **and** the enclosing transaction committed (the write's own error always wins over the transaction's outcome). `beforeTransaction` is unaffected — it still runs eagerly, before its write.
67
+
68
+ ```typescript
69
+
70
+ hooks: {
71
+ afterTransaction: async ({ status, item, error }) => {
72
+ if (status === 'rolled-back') {
73
+ // Now correctly fires even when this write itself succeeded but the
74
+ // OUTER context.transaction() callback later threw.
75
+ await billing.releaseSeat(error)
76
+ } else {
77
+ await billing.confirmSeat(item.seatId)
78
+ }
79
+ },
80
+ },
81
+ })
82
+ ```
83
+
84
+ Three behavior changes to be aware of when upgrading:
85
+
86
+ - A `context.transaction()` call can now **reject** with `AfterTransactionError` even after its underlying transaction already committed, if a deferred `afterTransaction` hook throws. A transaction/serialization error (e.g. `P2034`) still takes precedence and propagates unwrapped, so an existing `P2034` retry loop is unaffected.
87
+ - The deferred `item` a joined write's `afterTransaction` receives on commit is the row **as that write persisted it**, captured at write time — not re-read at flush — so it can be stale if a later write in the same transaction touches the same record.
88
+ - Transaction-boundary hooks (`beforeTransaction`/`afterTransaction`) on a joined write now always receive a context bound to the base client, never the transaction client — matching what top-level writes already did.
89
+
90
+ A write with no transaction owner at all (an app-managed `prisma.$transaction`, or a client that cannot open one, e.g. a bare test mock) is unaffected and still fires `afterTransaction` optimistically at write time.
91
+
92
+ See `docs/adr/0028-a-transaction-boundary-hook-reports-the-outermost-transaction.md` and the "In-transaction vs transaction-boundary hooks" section of the hooks concept doc.
93
+
94
+ - [#924](https://github.com/OpenSaasAU/stack/pull/924) [`05c9ad4`](https://github.com/OpenSaasAU/stack/commit/05c9ad40f8c4e76718d870e0c1c02511a3475943) Thanks [@borisno2](https://github.com/borisno2)! - Fix `FieldAccess['read']` typing `item` as absent when Field Visibility always passes the fetched row. A field `read` rule that reads a property off `item` now compiles without a cast, `any`, or non-null assertion:
95
+
96
+ ```ts
97
+ // Before (required a cast/assertion — `item` was typed `undefined`)
98
+ internalNotes: text({
99
+ access: {
100
+ read: ({ item, session }) => item!.ownerId === session?.userId,
101
+ } as FieldAccessControl,
102
+ })
103
+
104
+ // After (compiles as written — `item` is typed as the row)
105
+ internalNotes: text({
106
+ access: { read: ({ item, session }) => item.ownerId === session?.userId },
107
+ })
108
+ ```
109
+
110
+ `FieldAccess['read']` now accepts only the single `operation: 'read'` call shape (rather than the full `read | create | update` union `FieldAccess['create']`/`FieldAccess['update']` still accept), so a rule written for the `read` slot never needs to narrow on `operation` to use `item`. The `create` branch — where there genuinely is no row yet — is unchanged.
111
+
112
+ ### Patch Changes
113
+
114
+ - [#947](https://github.com/OpenSaasAU/stack/pull/947) [`5f00c3a`](https://github.com/OpenSaasAU/stack/commit/5f00c3a456295a1125281a4227309a8f8c6d853d) Thanks [@borisno2](https://github.com/borisno2)! - Clean up comments across `access/`, `config/`, `fields/`, `filter/`, `hooks/`, `lib/`, `mcp/`, `query/`, `utils/` and `validation/` per the CLAUDE.md Comments rule. No behavior changes.
115
+
116
+ - [#946](https://github.com/OpenSaasAU/stack/pull/946) [`4d8b654`](https://github.com/OpenSaasAU/stack/commit/4d8b654d099ce13d00893ebc4ce904fa69f2c47a) Thanks [@borisno2](https://github.com/borisno2)! - Restore two comments in `src/context/` that were trimmed too far in a prior comment cleanup ([#945](https://github.com/OpenSaasAU/stack/issues/945)). No behavior changes.
117
+
118
+ - [#920](https://github.com/OpenSaasAU/stack/pull/920) [`e0baadd`](https://github.com/OpenSaasAU/stack/commit/e0baaddade059cfea639d232f6953fc8c339f6f4) Thanks [@borisno2](https://github.com/borisno2)! - `findMany`/`count` now reject an undeclared `where`/`orderBy` key (including nested inside `AND`/`OR`/`NOT` or a relation filter), closing the same back-relation surface [#564](https://github.com/OpenSaasAU/stack/issues/564) closed on writes. `sudo` still bypasses.
119
+
120
+ - [#945](https://github.com/OpenSaasAU/stack/pull/945) [`ab4a5dd`](https://github.com/OpenSaasAU/stack/commit/ab4a5ddd83eebcf85d4a98f210cd378b974725f5) Thanks [@borisno2](https://github.com/borisno2)! - Clean up comments in `src/context/` per the CLAUDE.md Comments rule. No behavior changes.
121
+
122
+ - [#929](https://github.com/OpenSaasAU/stack/pull/929) [`94802ee`](https://github.com/OpenSaasAU/stack/commit/94802eee3b2fdc64fab4b576945820a6df9311c5) Thanks [@borisno2](https://github.com/borisno2)! - Fix: a relation filter in `where` (`some`/`every`/`none`/`is`/`isNot`) no longer bypasses the related list's `query` access — it is now scoped exactly like `include` already is, recursing through every hop of a chain, on both `findMany` and `count`. A filter through a related list that denies query access now throws `RelationFilterAccessDeniedError` instead of silently running unscoped; field-level `read` access on the related list also now applies to keys named inside the filter. `@opensaas/stack-ui`'s admin list view no longer needs its own relationship label-filter access fold, since the engine now covers it.
123
+
124
+ - [#931](https://github.com/OpenSaasAU/stack/pull/931) [`114302b`](https://github.com/OpenSaasAU/stack/commit/114302b95129484fadb6a1a640435ab1a5d2d102) Thanks [@borisno2](https://github.com/borisno2)! - Correct `ListIndex`/`db.indexes` doc comments, which wrongly claimed an entry must span two or more fields — a single-field entry is fully supported and now documented as such.
125
+
126
+ ## 0.38.0
127
+
128
+ ### Minor Changes
129
+
130
+ - [#873](https://github.com/OpenSaasAU/stack/pull/873) [`b21d8b2`](https://github.com/OpenSaasAU/stack/commit/b21d8b2af43f7a2a7ea10a89cfb39140a856bd68) Thanks [@borisno2](https://github.com/borisno2)! - Naming a relation in an `include` now fetches only that relation's own columns and stops, at every level — not just the root. This completes ADR-0024 (a bare read fetches scalars, never relations): reaching a relation's own relations means naming them too, e.g. `include: { author: { include: { organization: true } } }` rather than relying on `include: { author: true }` to pull `organization` in automatically. A relation nobody named (caller `include`, fragment `query`, or a field's `needs`) never has its list's operation-level `query` access evaluated at all.
131
+
132
+ **This is a silent break — detect it before you upgrade.** An `include` that named a relation bare and read past it (`item.<named>[0].<unnamed>`) now gets `undefined` for the unnamed part, with no error. Grep your codebase for `include: {` calls whose consumers read a second hop off a bare-named relation, and add the deeper relation explicitly:
133
+
134
+ ```typescript
135
+ // Before: relied on `author` auto-expanding its own `organization` relation
136
+ const post = await context.db.post.findUnique({
137
+ where: { id },
138
+ include: { author: true },
139
+ })
140
+ post.author.organization // silently undefined now
141
+
142
+ // After: name the relation you actually need
143
+ const post = await context.db.post.findUnique({
144
+ where: { id },
145
+ include: { author: { include: { organization: true } } },
146
+ })
147
+ post.author.organization // present
148
+ ```
149
+
150
+ `AccessScopeDepthExceededError` (thrown when an `include` names a relation past `READ_INCLUDE_MAX_DEPTH`) keeps its type, fields, and throw sites — only its message wording changed, from describing an inability to scope to describing a cost refusal, since the depth cap is now a cost limit rather than a security boundary (nothing walks the relationship graph unprompted anymore).
151
+
152
+ - [#890](https://github.com/OpenSaasAU/stack/pull/890) [`17eb72f`](https://github.com/OpenSaasAU/stack/commit/17eb72f0a9a4b7508e3f318da66bb8d4c6cbd705) Thanks [@list({](https://github.com/list({)! - A computed field — any field carrying a `resolveOutput` hook, virtual or not — is now computed if and only if a read is actually going to return it. A fragment `query` that selects three fields no longer runs every `resolveOutput` on the list and discards the rest: an unselected field's field-level read access is never evaluated and its hook never runs. Its declared relations (`needs`, ADR-0025) are fetched under exactly the same condition, folded recursively at every nesting level — a nested fragment selecting a subset computes only that subset, while a nested `include` still computes every computed field at that level, matching bare and `include`-based reads, which are unaffected: they still compute every computed field on the list, exactly as before. See ADR-0027.
153
+
154
+ **This is a silent break — detect it before you upgrade, the same way ADR-0024's and ADR-0026's were.** Two independent behaviors changed with no thrown error:
155
+
156
+ 1. **A hook's `item` never carries another computed field's resolved output, on any read path.** Previously a virtual field received the already-assembled, already-resolved object, so a virtual field could read an _earlier-declared_ virtual (or any field carrying its own `resolveOutput`, e.g. a `password()`'s wrapper or a formatted display field) and see its resolved value — working only by declaration order, with reordering two fields silently changing the result. Now every computed field's hook sees only the row's stored columns and its own declared dependencies; reaching for a sibling that is itself computed finds nothing there (or its raw stored form, never the wrapped/resolved value), the same as reaching for a field that was never declared. **Grep your config for a `resolveOutput` whose `item` reads a field that is itself computed** — virtual fields reading other virtual fields, or a hook reading a stored field that carries its own `resolveOutput` (a password wrapper, a formatted date) — and recompute from the shared stored columns instead of relying on another field's hook having already run.
157
+ 2. **A field's hook no longer runs just because it's on the list — only because a read selects it.** If you relied on a `resolveOutput` hook running for a side effect (logging, cache warming) on every read regardless of a fragment's own field selection, that side effect now only fires when the fragment actually names the field. **Grep for a fragment `query` that intentionally omits a field whose hook you were relying on for a side effect**, and select that field explicitly (or move the side effect to a hook that isn't projection-gated, e.g. `afterOperation`).
158
+
159
+ A hookless virtual field (one with `access.read` but no `resolveOutput`) no longer has its read access evaluated at all on any read — such a field can never produce output, so under this rule it does no work at all.
160
+
161
+ ```typescript
162
+ // Before: `displayName` (declared after `fullNameCached`) could read the
163
+ // latter's resolved value purely because of declaration order.
164
+
165
+ fields: {
166
+ firstName: text(),
167
+ lastName: text(),
168
+ fullNameCached: virtual({
169
+ type: 'string',
170
+ hooks: { resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}` },
171
+ }),
172
+ displayName: virtual({
173
+ type: 'string',
174
+ // item.fullNameCached is now always undefined here — recompute from
175
+ // the shared stored columns instead.
176
+ hooks: { resolveOutput: ({ item }) => `${item.fullNameCached} (${item.firstName[0]}.)` },
177
+ }),
178
+ },
179
+ })
180
+
181
+ // After: compute from the stored columns both fields actually share.
182
+ displayName: virtual({
183
+ type: 'string',
184
+ hooks: {
185
+ resolveOutput: ({ item }) => `${item.firstName} ${item.lastName} (${item.firstName[0]}.)`,
186
+ },
187
+ }),
188
+ ```
189
+
190
+ ### Patch Changes
191
+
192
+ - [#873](https://github.com/OpenSaasAU/stack/pull/873) [`b21d8b2`](https://github.com/OpenSaasAU/stack/commit/b21d8b2af43f7a2a7ea10a89cfb39140a856bd68) Thanks [@borisno2](https://github.com/borisno2)! - Fix `needs` declarations being dropped beneath a caller-named relation that revisits a list (e.g. `include: { author: { include: { posts: true } } }`, or a self-referential `parent`), which left the revisited list's computed fields resolving over `undefined`.
193
+
3
194
  ## 0.37.0
4
195
 
5
196
  ### Minor Changes
package/CLAUDE.md CHANGED
@@ -170,6 +170,27 @@ Key points:
170
170
  - If the client cannot open an interactive transaction (e.g. a test mock, or you
171
171
  are already inside one), `fn` runs directly with identical hook/access
172
172
  semantics. See ADR-0012.
173
+ - **A transaction-boundary `afterTransaction` reports the OUTERMOST transaction,
174
+ not its own write's return (ADR-0028).** A write nested in `context.transaction()`
175
+ (or a hook's own `context.db` write) cannot itself observe when the enclosing
176
+ transaction settles, so its `afterTransaction` is deferred until the owner —
177
+ `context.transaction()`, or the Write Pipeline when it opened the transaction —
178
+ observes the real commit/rollback. `beforeTransaction` stays eager in every
179
+ case, so under `context.transaction()` it runs with that transaction already
180
+ open (keep it fast; a `context.db` write from it can block on rows the
181
+ transaction itself is writing). Status is a conjunction — `committed` iff the
182
+ write itself succeeded AND the enclosing transaction committed, the write's
183
+ own error always winning otherwise — and the deferred `item` is the row as
184
+ that write persisted it, not re-read at flush (so a later same-record write in
185
+ the same transaction leaves it stale). Because of this, a **rejected
186
+ `context.transaction()` no longer implies rollback**: a deferred hook that
187
+ throws after a successful commit rejects the call with `AfterTransactionError`
188
+ over already-final data, though a transaction/serialization error (e.g.
189
+ `P2034`) still takes precedence and propagates unwrapped. A write with no
190
+ transaction owner at all (an app-managed `prisma.$transaction`, or a client —
191
+ e.g. a bare test mock — that cannot open one) still fires `afterTransaction`
192
+ optimistically at write time, unchanged. See ADR-0028 and the hooks concept
193
+ doc.
173
194
 
174
195
  ### Generators (`src/generator/`)
175
196
 
@@ -249,7 +270,25 @@ Reads run no `afterOperation` (list or field):
249
270
 
250
271
  ### A Bare Read Fetches Scalars, Not Relations (ADR-0024)
251
272
 
252
- A read with no `include` and no fragment `query` returns the row's own columns plus its virtual fields — **never relations** — matching Prisma's own default. `findUnique`, `findMany`, and a singleton's `get()` all follow this rule uniformly, under sudo and under a session alike. Relations are fetched only when a caller names them via `include` or a fragment `query`, at which point the existing merge-with-access-control path (`mergeIncludeWithAccessControl`) applies exactly as before (#566/#830 unaffected). Foreign-key columns (e.g. `authorId`) are unaffected and always returned, so a relation stays reachable by id without an `include`. A `resolveOutput` hook that issues its own bare `context.db` read is subject to the same rule — reading `item.<relation>` inside such a hook silently returns `undefined` unless the hook's own read names that relation. See `docs/adr/0024-a-read-with-no-include-fetches-scalars-not-relations.md`.
273
+ A read with no `include` and no fragment `query` returns the row's own columns plus its virtual fields — **never relations** — matching Prisma's own default. `findUnique`, `findMany`, and a singleton's `get()` all follow this rule uniformly, under sudo and under a session alike. Relations are fetched only when a caller names them via `include` or a fragment `query`, at which point the caller-directed access-scoping walk (`buildAccessScopedInclude`, ADR-0026) applies (#566/#830 unaffected). Foreign-key columns (e.g. `authorId`) are unaffected and always returned, so a relation stays reachable by id without an `include`. A `resolveOutput` hook that issues its own bare `context.db` read is subject to the same rule — reading `item.<relation>` inside such a hook silently returns `undefined` unless the hook's own read names that relation. See `docs/adr/0024-a-read-with-no-include-fetches-scalars-not-relations.md`.
274
+
275
+ ### Naming a Relation Fetches Its Columns, Not Its Subtree (ADR-0026)
276
+
277
+ The bare-read rule above applies at **every level**, not only the root: naming a relation in an `include` fetches that relation's own columns and stops — its own further relations are returned only if the request nests an `include` for them too. `include: { author: true }` returns `author`'s scalar columns; reaching `author.organization` means writing `include: { author: { include: { organization: true } } }`. This is the "One hop" rule (see the glossary entry in `CONTEXT.md`).
278
+
279
+ The read pipeline is **caller-directed**: `buildAccessScopedInclude` walks only the branches a request (a caller `include`, a fragment `query`'s projection, or a field's folded `needs`) itself names, and never evaluates a related list's operation-level `query` access for a relation nobody asked for. There is no separate "build the full access-scoped tree for the whole list, then reconcile against what was requested" pass — the old `buildIncludeWithAccessControl` + `mergeIncludeWithAccessControl` two-step this replaced. `READ_INCLUDE_MAX_DEPTH` is a cost limit on how deep a request may reach, not a security boundary: nothing walks the relationship graph unprompted anymore, so there is no unscoped subtree left to fail open on past the cap — a request naming a relation at or beyond the cap still throws `AccessScopeDepthExceededError` (ADR-0022), now worded as a cost refusal rather than a scoping failure.
280
+
281
+ **Migration note (silent break):** an `include` that named a relation bare and read past it one hop (`item.<named>[0].<unnamed>`) now gets `undefined` for the unnamed part — no error. Grep for caller includes whose consumers read past the relation actually named, and add the deeper relation explicitly to the `include`.
282
+
283
+ A computed field's declared dependency (`needs`, ADR-0025, below) folds in at **every** relation it's reached through, including one added purely to satisfy another field's own `needs` — the fold recurses through `foldDeclaredDependencies` rather than riding a caller-named relation's auto-expanded subtree, since nothing auto-expands anymore. See `docs/adr/0026-naming-a-relation-fetches-its-columns-not-its-subtree.md`.
284
+
285
+ ### A Computed Field Runs Only When It Is Going To Be Returned (ADR-0027)
286
+
287
+ A computed field — any field carrying a `resolveOutput` hook, virtual or not — is computed **if and only if the read is actually going to return it**, and its declared relations (`needs`) are fetched under exactly the same condition. A fragment `query` selecting three fields runs only those three fields' hooks (and folds only their `needs`); a field it doesn't select does no work at all — neither its field-level `read` access nor its hook runs. This is **projection-aware, never access-aware**: a fragment's own field selection is the only thing that restricts a level this way. A bare read or an `include`-based read is unaffected — every computed field on the list still computes, exactly as before, since neither ever had a narrower field selection to restrict by. The rule applies at every nesting level: a nested fragment selecting a subset computes only that subset there; a nested `include` still computes every computed field at that level.
288
+
289
+ **A computed field's hook never sees another computed field's resolved output**, on any read path — only the row's stored columns and its own declared dependencies. A sibling field that was skipped (unselected by a fragment) or denied by field-level access is absent from what the hook sees, never present holding its raw pre-hook value — reaching for it finds nothing there, the same as reaching for a relation never declared via `needs`. Before this, a virtual field received the already-assembled, already-resolved object, so a virtual field could accidentally read an earlier-declared virtual's resolved value purely by declaration order; reordering two such fields silently changed the result. That accidental coupling is gone: recompute from the stored columns both fields share instead.
290
+
291
+ A hookless virtual field (one with `access.read` but no `resolveOutput`) has its read access evaluated on no read at all — such a field can never produce output, so there's nothing to preserve access side effects for. See `docs/adr/0027-a-computed-field-runs-only-when-it-is-going-to-be-returned.md` and the "Computed field" glossary entry in `CONTEXT.md`.
253
292
 
254
293
  ### Context Type Safety
255
294
 
@@ -402,13 +441,13 @@ User: list({
402
441
 
403
442
  // Usage
404
443
  const user = await context.db.user.findUnique({ where: { id } })
405
- console.log(user.fullName) // "John Doe" — computed via resolveOutput on every read
444
+ console.log(user.fullName) // "John Doe" — computed via resolveOutput whenever the read returns it
406
445
  ```
407
446
 
408
447
  **Key characteristics:**
409
448
 
410
449
  - Not stored in database (no Prisma column created)
411
- - Computed via `resolveOutput` on every read (`select` is not honoured narrow with `include`/fragment `query`)
450
+ - Computed via `resolveOutput` on every bare/`include`-based read; on a fragment `query` read, only when the fragment selects it (ADR-0027) — `select` is still not honoured, narrow with `include`/fragment `query`
412
451
  - Must provide `type` (TypeScript type string) and `resolveOutput` hook
413
452
  - Can optionally provide `resolveInput` for write side effects
414
453
  - Useful for derived values, computed properties, and external API sync
@@ -1,125 +1,83 @@
1
- import type { Session, AccessContext, PrismaFilter } from './types.js';
2
- import type { OpenSaasConfig, FieldConfig } from '../config/types.js';
1
+ import type { Session, AccessContext } from './types.js';
2
+ import type { OpenSaasConfig, FieldConfig, ListConfig } from '../config/types.js';
3
3
  /**
4
- * Access Filter phase 1 of the two-phase read (pre-query).
4
+ * Build the access-scoped `include` for exactly the relations a read
5
+ * requested, recursing only into branches `requestedInclude` itself names.
5
6
  *
6
- * This module scopes which rows and relationships the database is allowed to
7
- * return, before the query runs. It evaluates *operation-level* `query` access
8
- * on related lists and turns the results into a Prisma `include`/`where` clause,
9
- * so denied rows and relations never leave the database.
7
+ * For each key in `requestedInclude`:
8
+ * - Not a config-declared relationship access control does not govern it;
9
+ * passed through unchanged (e.g. a fragment/caller key that isn't a
10
+ * relationship at all).
11
+ * - A declared relationship whose related list's `query` access denies it
12
+ * (`=== false`) → dropped entirely, no matter what the request asked for
13
+ * nested beneath it (#566): the caller chooses *which* relations, access
14
+ * control chooses *whether* and *with what filter*.
15
+ * - Otherwise → the access `where` is AND-combined with any caller-supplied
16
+ * nested `where` (never replaced — the other half of #566), a
17
+ * caller-supplied `take` rides through unchanged (#752), and — the "One
18
+ * hop" rule (ADR-0026) — nested relations are scoped ONLY if
19
+ * `requestedInclude` itself named a nested `include` here. A bare relation
20
+ * (or one with no nested `include`) fetches its own columns and stops: no
21
+ * recursive call, no access evaluation on anything beneath it.
10
22
  *
11
- * Phase 2 (post-query field stripping + `resolveOutput` + virtual computation)
12
- * lives in `field-visibility.ts`. The two phases cannot be merged: virtual
13
- * fields are computed in JavaScript and post-query field access can depend on
14
- * the fetched row, neither of which is expressible in SQL. See
15
- * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
16
- * glossary in `CONTEXT.md`.
23
+ * **Depth is a cost limit, not a cycle guard (ADR-0026).** A `requestedInclude`
24
+ * is always a finite literal the caller's own object, or
25
+ * `foldDeclaredDependencies`'s already-cycle-guarded fold so this recursion
26
+ * cannot loop unboundedly on its own; nothing here walks the relationship
27
+ * graph unprompted. `READ_INCLUDE_MAX_DEPTH` still bounds how deep a request
28
+ * may reach, fail-closed per ADR-0022: a request naming anything at or past
29
+ * the cap throws `AccessScopeDepthExceededError` rather than silently
30
+ * returning less than what was asked for.
17
31
  */
18
- /** A single relation entry in a Prisma `include` object (see below). */
19
- type IncludeEntry = boolean | {
20
- where?: PrismaFilter;
21
- include?: IncludeObject;
22
- take?: number;
23
- };
24
- type IncludeObject = Record<string, IncludeEntry>;
25
- /**
26
- * The result of trying to compute an access-controlled include for a list's
27
- * fields. `buildIncludeWithAccessControl` used to collapse three unrelated
28
- * outcomes into a single overloaded `undefined`: "inside a resolveOutput
29
- * context", "hit the depth cap", and "no relationships to scope" all looked
30
- * identical to callers, which is what let a depth-capped relation pass
31
- * through unscoped (issue #830). This discriminated result keeps them
32
- * distinguishable all the way to `mergeIncludeWithAccessControl`, which is the
33
- * only place that knows whether a caller actually asked for the part that
34
- * couldn't be scoped.
35
- *
36
- * - `scoped`: relationships were found and (to the extent depth allows)
37
- * access-controlled; `include` is the resulting tree.
38
- * - `nothing-to-scope`: the list genuinely has no relationships to scope, OR
39
- * we are inside a resolveOutput/virtual-field context and deliberately did
40
- * not descend into a relation's own nested relations. Passing the caller's
41
- * include through unchanged here is correct, not a leak.
42
- * - `depth-exceeded`: we could not evaluate this level at all because it sits
43
- * at or past `READ_INCLUDE_MAX_DEPTH`. This is a denial: a caller `include`
44
- * that reaches here must be rejected, not passed through.
45
- */
46
- export type AccessIncludeResult = {
47
- kind: 'scoped';
48
- include: RichIncludeObject;
49
- } | {
50
- kind: 'nothing-to-scope';
51
- } | {
52
- kind: 'depth-exceeded';
53
- };
54
- /** A relation entry in the rich, provenance-carrying tree `buildIncludeWithAccessControl` builds internally. */
55
- type RichIncludeEntry = {
56
- where?: PrismaFilter;
57
- nested: AccessIncludeResult;
58
- };
59
- type RichIncludeObject = Record<string, RichIncludeEntry>;
60
- /**
61
- * Collapse an `AccessIncludeResult` to the plain Prisma `include` shape used
62
- * when there is no caller-supplied include to merge against (the direct
63
- * auto-include path). `nothing-to-scope` and `depth-exceeded` both become
64
- * `undefined` here — at this call site nothing was explicitly requested past
65
- * either boundary, so there is nothing to deny.
66
- */
67
- export declare function toPrismaInclude(result: AccessIncludeResult): IncludeObject | undefined;
68
- /**
69
- * Build the access-controlled include for a list's fields.
70
- *
71
- * This allows us to filter relationships at the database level instead of in
72
- * memory. Returns an {@link AccessIncludeResult} rather than a plain include
73
- * object so that `mergeIncludeWithAccessControl` can tell a genuine "nothing
74
- * to scope" apart from "the engine hit its depth cap" (see that type's doc
75
- * comment and ADR-0022).
76
- */
77
- export declare function buildIncludeWithAccessControl(fieldConfigs: Record<string, FieldConfig>, args: {
32
+ export declare function buildAccessScopedInclude(requestedInclude: Record<string, unknown>, fieldConfigs: Record<string, FieldConfig>, args: {
78
33
  session: Session | null;
79
34
  context: AccessContext;
80
- }, config: OpenSaasConfig, depth?: number, visitedLists?: readonly string[]): Promise<AccessIncludeResult>;
35
+ }, config: OpenSaasConfig, listKey: string, depth?: number): Promise<Record<string, unknown>>;
81
36
  /**
82
- * Merge a caller-supplied `include` with the access-controlled include — phase-1
83
- * row/relation scoping for explicit caller selections.
84
- *
85
- * The caller's `include` decides WHICH relations to fetch; access control decides
86
- * WHETHER each relation may be fetched and WITH WHAT filter. Replacing the
87
- * access-controlled include with the caller's wholesale (the bug in #566) drops
88
- * every per-relation access `where` and denied-relation exclusion, silently
89
- * bypassing row-level access on any non-sudo read that passes `include`.
37
+ * Scope every relation filter (`some`/`every`/`none`/`is`/`isNot`) nested in a
38
+ * caller's `where` by the related list's own `query` access — the `where`
39
+ * counterpart to `buildAccessScopedInclude` above, closing #916 (the
40
+ * "unclosed half of ADR-0022"). `include` and `where` are fundamentally
41
+ * different requests (which relations come back, vs. which parent rows
42
+ * match), so this is a distinct function, but it shares every primitive that
43
+ * matters: `checkAccess`/`getRelatedListConfig` (the same access-evaluation
44
+ * calls `buildAccessScopedInclude` makes), `andWhere` (the same AND-fold), and
45
+ * `resolveQueryField`/`LOGICAL_OPERATORS`/`RELATION_QUANTIFIERS`/
46
+ * `walkWhereReadAccess` (the same shape-recognition and field-read check
47
+ * `query-validation.ts` already uses for #912/#915) — there is no second,
48
+ * parallel implementation of any of those decisions.
90
49
  *
91
- * For each relation the caller asks to include:
92
- * - If the relation is a config-declared relationship but is ABSENT from the
93
- * access-controlled include, its `query` access returned `false` it is DROPPED
94
- * (not fetched).
95
- * - If it is present (allowed, possibly with a filter), the access entry is used
96
- * as the base: the access `where` is AND-combined with any caller-supplied
97
- * nested `where`, and nested includes are recursively merged using the related
98
- * list's field configs (so deeply-nested selections are filtered at every
99
- * level). A bare caller `true` becomes the access-controlled shape (filter +
100
- * nested filtered include), never bare `true`.
101
- * - If the caller names a key that is NOT a config-declared relationship, it is
102
- * passed through unchanged (access control does not govern it).
50
+ * For each relationship key found (at any depth — the walk recurses through
51
+ * `AND`/`OR`/`NOT` and through every hop of a chain):
52
+ * - Not a declared relationship, or the key #912 already rejected (this walk
53
+ * runs strictly after that check) → passed through unchanged.
54
+ * - The related list's `query` access denies it (`=== false`) THROWS
55
+ * `RelationFilterAccessDeniedError`. Unlike `buildAccessScopedInclude`'s
56
+ * silent drop, this is a loud failure: a `where` predicate has no neutral
57
+ * "not requested" outcome the way a missing `include` key does, so a
58
+ * silently-empty match would itself be a distinguishable signal (ADR-0022).
59
+ * - Otherwise → the access filter (if any) is AND-combined into the relation
60
+ * quantifier's nested clause (never replacing the caller's own condition,
61
+ * mirroring `andWhere`'s include-side contract), keys inside that nested
62
+ * clause are checked against the RELATED list's field-level `read` access
63
+ * (`walkWhereReadAccess`, closing #915's stated gap for this path), and the
64
+ * walk recurses into the related list's own fields for a further hop.
103
65
  *
104
- * `accessControlledInclude` is the {@link AccessIncludeResult} for THIS level:
105
- * - `nothing-to-scope` nothing to merge against (the list has no
106
- * relationships, or we're inside a resolveOutput context where the caller
107
- * include is irrelevant to begin with). Pass the caller's include through
108
- * unchanged this is a non-denial outcome, not "every relation denied".
109
- * - `depth-exceeded` the engine could not compute a scope for THIS level at
110
- * all because it sits at or past `READ_INCLUDE_MAX_DEPTH`. If the caller
111
- * named anything here, that is exactly the case that used to pass through
112
- * unscoped (issue #830): throw `AccessScopeDepthExceededError` instead. An
113
- * empty caller include at this level (nothing further requested) is not an
114
- * error — there's simply nothing to do.
115
- * - `scoped` → the normal per-relation merge below: a declared relationship
116
- * ABSENT from the access include was denied (drop it); one PRESENT is used
117
- * as the base, AND-combining `where`s and recursing into nested includes.
118
- *
119
- * `listKey` and `depth` are carried only to build a useful
120
- * `AccessScopeDepthExceededError` message; they do not affect merge behaviour.
66
+ * One quantifier is deliberately conservative rather than exactly precise:
67
+ * folding the access filter into `every`'s nested clause with a plain AND
68
+ * makes `every` require every related row to be BOTH access-visible AND
69
+ * matching, not "every access-visible row matches" (the latter needs a
70
+ * `NOT`/`OR` transform this does not attempt). The conservative version can
71
+ * only reject a query that the precise version would allow it never
72
+ * widens what a caller can learn so it is the safe direction to ship; a
73
+ * caller can observe that an inaccessible related row exists (an `every`
74
+ * that "should" pass instead fails), but never that row's field values,
75
+ * which is the property this ticket exists to close.
121
76
  */
122
- export declare function mergeIncludeWithAccessControl(callerInclude: Record<string, unknown>, accessControlledInclude: AccessIncludeResult, fieldConfigs: Record<string, FieldConfig>, config: OpenSaasConfig, listKey: string, depth?: number): Record<string, unknown>;
77
+ export declare function buildAccessScopedWhere(where: unknown, listConfig: ListConfig<any>, listName: string, config: OpenSaasConfig, args: {
78
+ session: Session | null;
79
+ context: AccessContext;
80
+ }): Promise<unknown>;
123
81
  /**
124
82
  * Remove keys that correspond to `virtual` fields from a Prisma `include`
125
83
  * object, recursing into nested relationship includes using the related
@@ -137,5 +95,4 @@ export declare function mergeIncludeWithAccessControl(callerInclude: Record<stri
137
95
  * no effect on whether the value appears in the result (#628).
138
96
  */
139
97
  export declare function stripVirtualFieldsFromInclude(include: Record<string, unknown> | undefined, fieldConfigs: Record<string, FieldConfig>, config: OpenSaasConfig): Record<string, unknown> | undefined;
140
- export {};
141
98
  //# sourceMappingURL=access-filter.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"access-filter.d.ts","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACtE,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAKrE;;;;;;;;;;;;;;GAcG;AAEH,wEAAwE;AACxE,KAAK,YAAY,GAAG,OAAO,GAAG;IAAE,KAAK,CAAC,EAAE,YAAY,CAAC;IAAC,OAAO,CAAC,EAAE,aAAa,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAC9F,KAAK,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAA;AAEjD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,iBAAiB,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,kBAAkB,CAAA;CAAE,GAC5B;IAAE,IAAI,EAAE,gBAAgB,CAAA;CAAE,CAAA;AAE9B,gHAAgH;AAChH,KAAK,gBAAgB,GAAG;IAAE,KAAK,CAAC,EAAE,YAAY,CAAC;IAAC,MAAM,EAAE,mBAAmB,CAAA;CAAE,CAAA;AAC7E,KAAK,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;AAqBzD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,mBAAmB,GAAG,aAAa,GAAG,SAAS,CAOtF;AAED;;;;;;;;GAQG;AACH,wBAAsB,6BAA6B,CACjD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,aAAa,CAAA;CACvB,EACD,MAAM,EAAE,cAAc,EACtB,KAAK,GAAE,MAAU,EAIjB,YAAY,GAAE,SAAS,MAAM,EAAO,GACnC,OAAO,CAAC,mBAAmB,CAAC,CAwE9B;AAiDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAgB,6BAA6B,CAC3C,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACtC,uBAAuB,EAAE,mBAAmB,EAC5C,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,MAAM,EACf,KAAK,GAAE,MAAU,GAChB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA2EzB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,MAAM,EAAE,cAAc,GACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAgCrC"}
1
+ {"version":3,"file":"access-filter.d.ts","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAgB,MAAM,YAAY,CAAA;AACtE,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAkFjF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,wBAAwB,CAC5C,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACzC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,aAAa,CAAA;CACvB,EACD,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,MAAM,EACf,KAAK,GAAE,MAAU,GAChB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAwDlC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,OAAO,EAEd,UAAU,EAAE,UAAU,CAAC,GAAG,CAAC,EAC3B,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,cAAc,EACtB,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,aAAa,CAAA;CACvB,GACA,OAAO,CAAC,OAAO,CAAC,CA0FlB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,MAAM,EAAE,cAAc,GACrB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CA+BrC"}