@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +191 -0
- package/CLAUDE.md +42 -3
- package/dist/access/access-filter.d.ts +70 -113
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +183 -210
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +148 -188
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts +66 -26
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +62 -17
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/declared-dependencies.test.d.ts +2 -0
- package/dist/access/declared-dependencies.test.d.ts.map +1 -0
- package/dist/access/declared-dependencies.test.js +226 -0
- package/dist/access/declared-dependencies.test.js.map +1 -0
- package/dist/access/depth-limits.d.ts +8 -7
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +8 -7
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/engine.d.ts +0 -20
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +0 -25
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +61 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +91 -12
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +39 -1
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +108 -42
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +229 -2
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-transforms.d.ts +0 -27
- package/dist/access/field-transforms.d.ts.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -5
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +89 -41
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +6 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +12 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/query-validation.d.ts +121 -0
- package/dist/access/query-validation.d.ts.map +1 -0
- package/dist/access/query-validation.js +292 -0
- package/dist/access/query-validation.js.map +1 -0
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +2 -10
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-label-filter.d.ts +23 -23
- package/dist/access/relationship-label-filter.d.ts.map +1 -1
- package/dist/access/relationship-label-filter.js +5 -76
- package/dist/access/relationship-label-filter.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +19 -56
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/transaction-registry.d.ts +45 -0
- package/dist/access/transaction-registry.d.ts.map +1 -0
- package/dist/access/transaction-registry.js +37 -0
- package/dist/access/transaction-registry.js.map +1 -0
- package/dist/access/types.d.ts +55 -59
- package/dist/access/types.d.ts.map +1 -1
- package/dist/config/index.d.ts +9 -7
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +10 -18
- package/dist/config/index.js.map +1 -1
- package/dist/config/label.d.ts +6 -13
- package/dist/config/label.d.ts.map +1 -1
- package/dist/config/label.js +6 -13
- package/dist/config/label.js.map +1 -1
- package/dist/config/nav-count.d.ts +11 -19
- package/dist/config/nav-count.d.ts.map +1 -1
- package/dist/config/nav-count.js +11 -19
- package/dist/config/nav-count.js.map +1 -1
- package/dist/config/plugin-engine.d.ts +0 -10
- package/dist/config/plugin-engine.d.ts.map +1 -1
- package/dist/config/plugin-engine.js +1 -37
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +94 -109
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +8 -31
- package/dist/context/apply-defaults.d.ts.map +1 -1
- package/dist/context/apply-defaults.js +12 -41
- package/dist/context/apply-defaults.js.map +1 -1
- package/dist/context/hook-pipeline.d.ts +4 -29
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +8 -41
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +4 -15
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +223 -215
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +7 -26
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +73 -171
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts +36 -47
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +92 -79
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +25 -64
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +81 -111
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/format-prisma-default.d.ts.map +1 -1
- package/dist/fields/format-prisma-default.js +4 -7
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +17 -2
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +136 -147
- package/dist/fields/index.js.map +1 -1
- package/dist/filter/collect.d.ts +26 -3
- package/dist/filter/collect.d.ts.map +1 -1
- package/dist/filter/collect.js +30 -12
- package/dist/filter/collect.js.map +1 -1
- package/dist/filter/filter.test.js +98 -15
- package/dist/filter/filter.test.js.map +1 -1
- package/dist/filter/index.d.ts +1 -0
- package/dist/filter/index.d.ts.map +1 -1
- package/dist/filter/index.js.map +1 -1
- package/dist/filter/map.d.ts.map +1 -1
- package/dist/filter/map.js +0 -5
- package/dist/filter/map.js.map +1 -1
- package/dist/filter/parse.d.ts.map +1 -1
- package/dist/filter/parse.js +2 -9
- package/dist/filter/parse.js.map +1 -1
- package/dist/filter/serialize.d.ts.map +1 -1
- package/dist/filter/serialize.js +2 -4
- package/dist/filter/serialize.js.map +1 -1
- package/dist/hooks/index.d.ts +4 -42
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +5 -59
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -4
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -0
- package/dist/internal.js.map +1 -1
- package/dist/lib/case-utils.d.ts +0 -32
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +0 -32
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/mcp/handler.d.ts +0 -4
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +12 -69
- package/dist/mcp/handler.js.map +1 -1
- package/dist/query/index.d.ts +55 -227
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +41 -117
- package/dist/query/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts +1 -1
- package/dist/query/relationship-options.js +1 -1
- package/dist/utils/password.d.ts +0 -68
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +7 -76
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/field-config.d.ts.map +1 -1
- package/dist/validation/field-config.js +0 -6
- package/dist/validation/field-config.js.map +1 -1
- package/dist/validation/schema.d.ts +0 -7
- package/dist/validation/schema.d.ts.map +1 -1
- package/dist/validation/schema.js +0 -10
- package/dist/validation/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +236 -272
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +117 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/engine.ts +0 -25
- package/src/access/errors.ts +97 -12
- package/src/access/field-access.test.ts +312 -2
- package/src/access/field-access.ts +118 -42
- package/src/access/field-transforms.ts +8 -39
- package/src/access/field-visibility.ts +99 -45
- package/src/access/index.ts +17 -5
- package/src/access/query-validation.ts +359 -0
- package/src/access/relationship-count.ts +3 -11
- package/src/access/relationship-label-filter.test.ts +19 -68
- package/src/access/relationship-label-filter.ts +27 -104
- package/src/access/transaction-registry.ts +58 -0
- package/src/access/types.ts +83 -86
- package/src/config/index.ts +10 -22
- package/src/config/label.ts +6 -13
- package/src/config/nav-count.ts +11 -19
- package/src/config/plugin-engine.ts +1 -37
- package/src/config/types.ts +95 -110
- package/src/context/apply-defaults.ts +12 -44
- package/src/context/hook-pipeline.ts +12 -67
- package/src/context/index.ts +311 -276
- package/src/context/nested-operations.ts +94 -226
- package/src/context/transaction-boundary.ts +113 -109
- package/src/context/write-pipeline.ts +99 -155
- package/src/fields/format-prisma-default.ts +4 -7
- package/src/fields/index.ts +155 -147
- package/src/filter/collect.ts +48 -12
- package/src/filter/filter.test.ts +153 -15
- package/src/filter/index.ts +1 -0
- package/src/filter/map.ts +0 -5
- package/src/filter/parse.ts +2 -9
- package/src/filter/serialize.ts +2 -4
- package/src/hooks/index.ts +5 -59
- package/src/index.ts +23 -4
- package/src/internal.ts +5 -0
- package/src/lib/case-utils.ts +0 -32
- package/src/mcp/handler.ts +13 -71
- package/src/query/index.ts +82 -235
- package/src/query/relationship-options.ts +1 -1
- package/src/utils/password.ts +7 -76
- package/src/validation/field-config.ts +0 -6
- package/src/validation/schema.ts +0 -10
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +727 -1
- package/tests/field-types.test.ts +222 -0
- package/tests/mcp-handler.test.ts +34 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tests/transaction-boundary-outermost.test.ts +530 -0
- package/tsconfig.tsbuildinfo +1 -1
package/.turbo/turbo-build.log
CHANGED
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
4
|
+
* Build the access-scoped `include` for exactly the relations a read
|
|
5
|
+
* requested, recursing only into branches `requestedInclude` itself names.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
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,
|
|
35
|
+
}, config: OpenSaasConfig, listKey: string, depth?: number): Promise<Record<string, unknown>>;
|
|
81
36
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* -
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
|
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,
|
|
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"}
|