@opensaas/stack-core 0.36.0 → 0.38.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 +121 -0
- package/CLAUDE.md +21 -3
- package/dist/access/access-filter.d.ts +30 -118
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +70 -206
- 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 +67 -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/errors.d.ts +12 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +16 -12
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +91 -17
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +1 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +126 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +38 -27
- package/dist/context/index.js.map +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +28 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/query/index.d.ts +29 -0
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +27 -0
- 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/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +84 -267
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +122 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/errors.ts +16 -12
- package/src/access/field-visibility.ts +99 -14
- package/src/access/index.ts +1 -7
- package/src/access/relationship-count.ts +1 -1
- package/src/config/index.ts +2 -0
- package/src/config/types.ts +130 -0
- package/src/context/index.ts +52 -33
- package/src/fields/index.ts +35 -5
- package/src/index.ts +2 -0
- package/src/query/index.ts +53 -0
- package/src/query/relationship-options.ts +1 -1
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +27 -0
- package/tests/field-types.test.ts +12 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tsconfig.tsbuildinfo +1 -1
package/.turbo/turbo-build.log
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,126 @@
|
|
|
1
1
|
# @opensaas/stack-core
|
|
2
2
|
|
|
3
|
+
## 0.38.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#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.
|
|
8
|
+
|
|
9
|
+
**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:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
// Before: relied on `author` auto-expanding its own `organization` relation
|
|
13
|
+
const post = await context.db.post.findUnique({
|
|
14
|
+
where: { id },
|
|
15
|
+
include: { author: true },
|
|
16
|
+
})
|
|
17
|
+
post.author.organization // silently undefined now
|
|
18
|
+
|
|
19
|
+
// After: name the relation you actually need
|
|
20
|
+
const post = await context.db.post.findUnique({
|
|
21
|
+
where: { id },
|
|
22
|
+
include: { author: { include: { organization: true } } },
|
|
23
|
+
})
|
|
24
|
+
post.author.organization // present
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`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).
|
|
28
|
+
|
|
29
|
+
- [#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.
|
|
30
|
+
|
|
31
|
+
**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:
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
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`).
|
|
35
|
+
|
|
36
|
+
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.
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// Before: `displayName` (declared after `fullNameCached`) could read the
|
|
40
|
+
// latter's resolved value purely because of declaration order.
|
|
41
|
+
|
|
42
|
+
fields: {
|
|
43
|
+
firstName: text(),
|
|
44
|
+
lastName: text(),
|
|
45
|
+
fullNameCached: virtual({
|
|
46
|
+
type: 'string',
|
|
47
|
+
hooks: { resolveOutput: ({ item }) => `${item.firstName} ${item.lastName}` },
|
|
48
|
+
}),
|
|
49
|
+
displayName: virtual({
|
|
50
|
+
type: 'string',
|
|
51
|
+
// item.fullNameCached is now always undefined here — recompute from
|
|
52
|
+
// the shared stored columns instead.
|
|
53
|
+
hooks: { resolveOutput: ({ item }) => `${item.fullNameCached} (${item.firstName[0]}.)` },
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// After: compute from the stored columns both fields actually share.
|
|
59
|
+
displayName: virtual({
|
|
60
|
+
type: 'string',
|
|
61
|
+
hooks: {
|
|
62
|
+
resolveOutput: ({ item }) => `${item.firstName} ${item.lastName} (${item.firstName[0]}.)`,
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Patch Changes
|
|
68
|
+
|
|
69
|
+
- [#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`.
|
|
70
|
+
|
|
71
|
+
## 0.37.0
|
|
72
|
+
|
|
73
|
+
### Minor Changes
|
|
74
|
+
|
|
75
|
+
- [#868](https://github.com/OpenSaasAU/stack/pull/868) [`6bf9dcb`](https://github.com/OpenSaasAU/stack/commit/6bf9dcb1b8d030d57371b6b4a4f55462eb8ab2eb) Thanks [@borisno2](https://github.com/borisno2)! - Add `db.indexes` to `ListConfig` for model-level composite `@@unique`/`@@index` constraints spanning two or more of a list's own fields — the multi-column case single-field `isIndexed` can't reach.
|
|
76
|
+
|
|
77
|
+
Entries name OpenSaaS field names, not raw database columns; the generator resolves a scalar field to its own name and a relationship field to its foreign key column:
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
Audition: list({
|
|
81
|
+
fields: {
|
|
82
|
+
student: relationship({ ref: 'Student.auditions' }),
|
|
83
|
+
production: relationship({ ref: 'Production.auditions' }),
|
|
84
|
+
},
|
|
85
|
+
db: {
|
|
86
|
+
// One audition per student per production — a DB-level backstop a
|
|
87
|
+
// hook's existence check alone can't provide against concurrent writes.
|
|
88
|
+
indexes: [{ fields: ['student', 'production'], unique: true }],
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
// Generates: @@unique([studentId, productionId])
|
|
92
|
+
|
|
93
|
+
AuthVerification: list({
|
|
94
|
+
fields: { identifier: text(), createdAt: timestamp() },
|
|
95
|
+
db: {
|
|
96
|
+
indexes: [
|
|
97
|
+
{
|
|
98
|
+
fields: ['identifier', { field: 'createdAt', sort: 'desc' }],
|
|
99
|
+
name: 'AuthVerification_identifier_createdAt_idx', // adopts an existing constraint name via Prisma's `map:`
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
// Generates: @@index([identifier, createdAt(sort: Desc)], map: "AuthVerification_identifier_createdAt_idx")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
An entry naming an unknown field, a virtual field, a to-many relationship, or the non-FK side of a one-to-one relationship fails `pnpm generate` with an error naming the list, the entry, and the bad field, rather than being silently dropped or emitted as invalid Prisma. A config with no `db.indexes` generates byte-for-byte identical output to before this change.
|
|
108
|
+
|
|
109
|
+
- [#870](https://github.com/OpenSaasAU/stack/pull/870) [`7b6189f`](https://github.com/OpenSaasAU/stack/commit/7b6189fa60119a45082ba62dd71d915d93de529c) Thanks [@relationship({](https://github.com/relationship({)! - A relationship field's foreign key can now be declared non-nullable via `db.isNullable: false` — the generated FK column and its relation field lose their `?` together. Omitting the option leaves every existing relationship unchanged (still nullable by default).
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
|
|
113
|
+
ref: 'User.sessions',
|
|
114
|
+
db: { isNullable: false },
|
|
115
|
+
})
|
|
116
|
+
// Generates: userId String (was String?)
|
|
117
|
+
// user User @relation(...) (was User?)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`@opensaas/stack-auth`'s derived Auth lists now use this to match better-auth's own Prisma schema: `Session.expiresAt`, `Verification.expiresAt`, and the `Session.user`/`Account.user` foreign keys generate as required instead of nullable.
|
|
121
|
+
|
|
122
|
+
**Migration note:** this changes the generated schema for existing greenfield apps. Running `opensaas generate` followed by `prisma db push`/`prisma migrate dev` will produce a migration that adds `NOT NULL` to `Session.expiresAt`, `Verification.expiresAt`, `Session.userId`, and `Account.userId`. Since better-auth's own adapter always writes these columns, no existing row should violate the new constraint — but back up production data before applying, as with any schema migration.
|
|
123
|
+
|
|
3
124
|
## 0.36.0
|
|
4
125
|
|
|
5
126
|
### Minor Changes
|
package/CLAUDE.md
CHANGED
|
@@ -249,7 +249,25 @@ Reads run no `afterOperation` (list or field):
|
|
|
249
249
|
|
|
250
250
|
### A Bare Read Fetches Scalars, Not Relations (ADR-0024)
|
|
251
251
|
|
|
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
|
|
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 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`.
|
|
253
|
+
|
|
254
|
+
### Naming a Relation Fetches Its Columns, Not Its Subtree (ADR-0026)
|
|
255
|
+
|
|
256
|
+
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`).
|
|
257
|
+
|
|
258
|
+
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.
|
|
259
|
+
|
|
260
|
+
**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`.
|
|
261
|
+
|
|
262
|
+
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`.
|
|
263
|
+
|
|
264
|
+
### A Computed Field Runs Only When It Is Going To Be Returned (ADR-0027)
|
|
265
|
+
|
|
266
|
+
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.
|
|
267
|
+
|
|
268
|
+
**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.
|
|
269
|
+
|
|
270
|
+
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
271
|
|
|
254
272
|
### Context Type Safety
|
|
255
273
|
|
|
@@ -402,13 +420,13 @@ User: list({
|
|
|
402
420
|
|
|
403
421
|
// Usage
|
|
404
422
|
const user = await context.db.user.findUnique({ where: { id } })
|
|
405
|
-
console.log(user.fullName) // "John Doe" — computed via resolveOutput
|
|
423
|
+
console.log(user.fullName) // "John Doe" — computed via resolveOutput whenever the read returns it
|
|
406
424
|
```
|
|
407
425
|
|
|
408
426
|
**Key characteristics:**
|
|
409
427
|
|
|
410
428
|
- Not stored in database (no Prisma column created)
|
|
411
|
-
- Computed via `resolveOutput` on every read (`select` is not honoured
|
|
429
|
+
- 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
430
|
- Must provide `type` (TypeScript type string) and `resolveOutput` hook
|
|
413
431
|
- Can optionally provide `resolveInput` for write side effects
|
|
414
432
|
- Useful for derived values, computed properties, and external API sync
|
|
@@ -1,125 +1,38 @@
|
|
|
1
|
-
import type { Session, AccessContext
|
|
1
|
+
import type { Session, AccessContext } from './types.js';
|
|
2
2
|
import type { OpenSaasConfig, FieldConfig } from '../config/types.js';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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).
|
|
4
|
+
* Build the access-scoped `include` for exactly the relations a read
|
|
5
|
+
* requested, recursing only into branches `requestedInclude` itself names.
|
|
6
|
+
*
|
|
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.
|
|
22
|
+
*
|
|
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.
|
|
76
31
|
*/
|
|
77
|
-
export declare function
|
|
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,
|
|
81
|
-
/**
|
|
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`.
|
|
90
|
-
*
|
|
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).
|
|
103
|
-
*
|
|
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.
|
|
121
|
-
*/
|
|
122
|
-
export declare function mergeIncludeWithAccessControl(callerInclude: Record<string, unknown>, accessControlledInclude: AccessIncludeResult, fieldConfigs: Record<string, FieldConfig>, config: OpenSaasConfig, listKey: string, depth?: number): Record<string, unknown>;
|
|
35
|
+
}, config: OpenSaasConfig, listKey: string, depth?: number): Promise<Record<string, unknown>>;
|
|
123
36
|
/**
|
|
124
37
|
* Remove keys that correspond to `virtual` fields from a Prisma `include`
|
|
125
38
|
* object, recursing into nested relationship includes using the related
|
|
@@ -137,5 +50,4 @@ export declare function mergeIncludeWithAccessControl(callerInclude: Record<stri
|
|
|
137
50
|
* no effect on whether the value appears in the result (#628).
|
|
138
51
|
*/
|
|
139
52
|
export declare function stripVirtualFieldsFromInclude(include: Record<string, unknown> | undefined, fieldConfigs: Record<string, FieldConfig>, config: OpenSaasConfig): Record<string, unknown> | undefined;
|
|
140
|
-
export {};
|
|
141
53
|
//# 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,MAAM,oBAAoB,CAAA;AA6ErE;;;;;;;;;;;;;;;;;;;;;;;;;;;;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;;;;;;;;;;;;;;;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"}
|