@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
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
import type { Session, AccessContext } from './types.js'
|
|
2
2
|
import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
|
|
3
|
-
import { checkAccess, getRelatedListConfig } from './engine.js'
|
|
4
|
-
import { mergeResolvedMember } from './relationship-count.js'
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* Access-scoped to-one relationship label filters for the admin list view
|
|
8
|
-
* (issue #749).
|
|
6
|
+
* (issue #749) — NOW A PASS-THROUGH.
|
|
9
7
|
*
|
|
10
8
|
* A to-one relationship's Filter spec (`author:Ada` → `{ author: { is: { name:
|
|
11
|
-
* { contains: 'Ada' } } } }`)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* against a `User` list it cannot query).
|
|
9
|
+
* { contains: 'Ada' } } } }`) produces exactly the `{ is: {...} }` shape a
|
|
10
|
+
* relation filter uses. Before #916, the engine did not scope relation
|
|
11
|
+
* filters in `where` at all, so this module was the only place the related
|
|
12
|
+
* list's `query` access was folded into that nested `is` clause (mirroring
|
|
13
|
+
* `relationship-count.ts`'s `_count` folding) — otherwise a session could
|
|
14
|
+
* distinguish parent rows by a related field it could not itself read (e.g.
|
|
15
|
+
* binary-searching `author:A`, `author:Ad`, `author:Ada` against a `User`
|
|
16
|
+
* list it cannot query).
|
|
20
17
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
18
|
+
* #916 closed that gap in the engine itself: `context.db.*.findMany`/`count`
|
|
19
|
+
* now scope every relation filter in `where` — including this exact `is`
|
|
20
|
+
* shape — via `buildAccessScopedWhere` (`access-filter.ts`), applied
|
|
21
|
+
* automatically to whatever `where` this module's caller (`ListView.tsx`)
|
|
22
|
+
* hands to the secured context. Folding the same access filter here as well
|
|
23
|
+
* would be redundant work producing an identical result (the engine ANDs the
|
|
24
|
+
* same filter in a second time), so this module's own fold was removed —
|
|
25
|
+
* `resolveRelationshipLabelFilters` now returns `where` unchanged. The
|
|
26
|
+
* exported functions are kept, unchanged in shape, only because they remain
|
|
27
|
+
* part of `@opensaas/stack-core`'s public surface; removing them outright
|
|
28
|
+
* would be a breaking change this fix does not need to make.
|
|
27
29
|
*/
|
|
28
30
|
|
|
29
31
|
type LabelFilterArgs = {
|
|
@@ -46,95 +48,16 @@ export function isToOneRelationshipField(field: FieldConfig | undefined): boolea
|
|
|
46
48
|
)
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
/** Narrow a filter member's field value to a `{ is: {...} } }` clause, if it is one. */
|
|
50
|
-
function readIsClause(value: unknown): Record<string, unknown> | null {
|
|
51
|
-
if (!value || typeof value !== 'object') return null
|
|
52
|
-
const isValue = (value as Record<string, unknown>).is
|
|
53
|
-
if (!isValue || typeof isValue !== 'object') return null
|
|
54
|
-
return isValue as Record<string, unknown>
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Resolve one to-one relationship label-filter member into an access-scoped
|
|
59
|
-
* condition by ANDing the related list's `query` access filter into the nested
|
|
60
|
-
* `is` clause. Returns `{ id: { in: [] } }` (never matches) when the related
|
|
61
|
-
* list is fully denied, the original member unchanged when it is fully
|
|
62
|
-
* readable, and the `is` clause ANDed with the access filter otherwise.
|
|
63
|
-
*/
|
|
64
|
-
async function resolveOneLabelFilter(
|
|
65
|
-
field: FieldConfig,
|
|
66
|
-
fieldName: string,
|
|
67
|
-
isClause: Record<string, unknown>,
|
|
68
|
-
args: LabelFilterArgs,
|
|
69
|
-
config: OpenSaasConfig,
|
|
70
|
-
): Promise<Record<string, unknown>> {
|
|
71
|
-
if (!('ref' in field) || typeof field.ref !== 'string') {
|
|
72
|
-
return { [fieldName]: { is: isClause } }
|
|
73
|
-
}
|
|
74
|
-
const related = getRelatedListConfig(field.ref, config)
|
|
75
|
-
if (!related) return { [fieldName]: { is: isClause } }
|
|
76
|
-
|
|
77
|
-
const queryAccess = related.listConfig.access?.operation?.query
|
|
78
|
-
const result = await checkAccess(queryAccess, { session: args.session, context: args.context })
|
|
79
|
-
|
|
80
|
-
if (result === false) return { id: { in: [] } }
|
|
81
|
-
if (result === true) return { [fieldName]: { is: isClause } }
|
|
82
|
-
return { [fieldName]: { is: { AND: [result, isClause] } } }
|
|
83
|
-
}
|
|
84
|
-
|
|
85
51
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* top-level AND members (the relationship Filter spec does not declare
|
|
89
|
-
* `freeText`, so it never participates in the free-text OR), so this walks
|
|
90
|
-
* only the top level. Returns the `where` unchanged when it contains no such
|
|
91
|
-
* members, so lists without to-one relationship filters pay nothing.
|
|
52
|
+
* No longer folds access into label filters — see the module doc above.
|
|
53
|
+
* Returns `where` unchanged.
|
|
92
54
|
*/
|
|
93
55
|
export async function resolveRelationshipLabelFilters(
|
|
94
56
|
where: Record<string, unknown> | undefined,
|
|
95
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo, kept for API compatibility (see module doc)
|
|
58
|
+
_listConfig: ListConfig<any>,
|
|
59
|
+
_args: LabelFilterArgs,
|
|
60
|
+
_config: OpenSaasConfig,
|
|
99
61
|
): Promise<Record<string, unknown> | undefined> {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const andValue = where.AND
|
|
103
|
-
const members: Array<Record<string, unknown>> = Array.isArray(andValue)
|
|
104
|
-
? (andValue as Array<Record<string, unknown>>)
|
|
105
|
-
: [where]
|
|
106
|
-
|
|
107
|
-
const findLabelFilter = (
|
|
108
|
-
member: Record<string, unknown>,
|
|
109
|
-
): { field: string; isClause: Record<string, unknown> } | null => {
|
|
110
|
-
for (const key of Object.keys(member)) {
|
|
111
|
-
const field = listConfig.fields[key]
|
|
112
|
-
if (!isToOneRelationshipField(field)) continue
|
|
113
|
-
const isClause = readIsClause(member[key])
|
|
114
|
-
if (!isClause) continue
|
|
115
|
-
return { field: key, isClause }
|
|
116
|
-
}
|
|
117
|
-
return null
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (!members.some((member) => findLabelFilter(member) !== null)) {
|
|
121
|
-
return where
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const resolvedMembers: Array<Record<string, unknown>> = []
|
|
125
|
-
for (const member of members) {
|
|
126
|
-
const found = findLabelFilter(member)
|
|
127
|
-
if (!found) {
|
|
128
|
-
resolvedMembers.push(member)
|
|
129
|
-
continue
|
|
130
|
-
}
|
|
131
|
-
const field = listConfig.fields[found.field]
|
|
132
|
-
const resolved = await resolveOneLabelFilter(field, found.field, found.isClause, args, config)
|
|
133
|
-
|
|
134
|
-
const siblings: Record<string, unknown> = { ...member }
|
|
135
|
-
delete siblings[found.field]
|
|
136
|
-
resolvedMembers.push(mergeResolvedMember(siblings, resolved))
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return resolvedMembers.length === 1 ? resolvedMembers[0] : { AND: resolvedMembers }
|
|
62
|
+
return where
|
|
140
63
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deferral registry for transaction-boundary hooks on a JOINED write (ADR-0028,
|
|
3
|
+
* issue #899).
|
|
4
|
+
*
|
|
5
|
+
* A write that joins a transaction it did not open — because the client it was
|
|
6
|
+
* handed exposes no way to open one (a Prisma transaction client, or a plain
|
|
7
|
+
* write issued from a hook whose context is rebound to one) — cannot itself
|
|
8
|
+
* observe when the enclosing transaction settles. Instead of firing
|
|
9
|
+
* `afterTransaction` optimistically at write time, it enqueues a flush here.
|
|
10
|
+
* The transaction owner (`context.transaction()`, or the Write Pipeline when it
|
|
11
|
+
* actually opens the transaction) drains the queue, in enqueue (write) order,
|
|
12
|
+
* once it observes its own transaction settle — supplying the real
|
|
13
|
+
* committed/rolled-back outcome.
|
|
14
|
+
*
|
|
15
|
+
* This is internal plumbing with no public surface: it is threaded through the
|
|
16
|
+
* same context-rebind path ADR-0010/0012 already use for `plugins` and
|
|
17
|
+
* `_resolveOutputChain` (see `AccessContext['_transactionOwner']`), never
|
|
18
|
+
* exposed on the public `StackContext` type.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The outcome the transaction OWNER observed for the transaction as a whole —
|
|
23
|
+
* distinct from a single write's own {@link import('../hooks/index.js').TransactionOutcome},
|
|
24
|
+
* which additionally carries that write's own persisted `item` or `error`.
|
|
25
|
+
*/
|
|
26
|
+
export interface TransactionSettleOutcome {
|
|
27
|
+
status: 'committed' | 'rolled-back'
|
|
28
|
+
error?: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One deferred write's flush: given the owner's settle outcome, runs that
|
|
33
|
+
* write's `afterTransaction` bracket and appends any hook errors to `errors`.
|
|
34
|
+
*/
|
|
35
|
+
export type QueuedTransactionFlush = (
|
|
36
|
+
settle: TransactionSettleOutcome,
|
|
37
|
+
errors: unknown[],
|
|
38
|
+
) => Promise<void>
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A FIFO queue of deferred transaction-boundary flushes, owned by whichever
|
|
42
|
+
* component opened the enclosing transaction. One registry per owned
|
|
43
|
+
* transaction; a nested `context.transaction()` joins its enclosing owner's
|
|
44
|
+
* registry rather than creating a second one.
|
|
45
|
+
*/
|
|
46
|
+
export class TransactionRegistry {
|
|
47
|
+
private readonly queue: QueuedTransactionFlush[] = []
|
|
48
|
+
|
|
49
|
+
enqueue(flush: QueuedTransactionFlush): void {
|
|
50
|
+
this.queue.push(flush)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async drain(settle: TransactionSettleOutcome, errors: unknown[]): Promise<void> {
|
|
54
|
+
for (const flush of this.queue) {
|
|
55
|
+
await flush(settle, errors)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/access/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Fragment, FieldSelection, ResultOf } from '../query/index.js'
|
|
2
|
+
import type { TransactionRegistry } from './transaction-registry.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Session interface - can be augmented by developers to add custom fields
|
|
@@ -37,9 +38,6 @@ export interface Session {
|
|
|
37
38
|
[key: string]: unknown
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
/**
|
|
41
|
-
* Generic Prisma model delegate type
|
|
42
|
-
*/
|
|
43
41
|
export type PrismaModelDelegate = {
|
|
44
42
|
findUnique: (args: unknown) => Promise<unknown>
|
|
45
43
|
findFirst: (args: unknown) => Promise<unknown>
|
|
@@ -50,12 +48,8 @@ export type PrismaModelDelegate = {
|
|
|
50
48
|
count: (args?: unknown) => Promise<number>
|
|
51
49
|
}
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
* This is intentionally permissive to allow actual PrismaClient types
|
|
56
|
-
* Uses `any` because Prisma generates highly complex client types that are difficult to constrain
|
|
57
|
-
* This type is used as a generic constraint and the actual type safety comes from TPrisma parameter
|
|
58
|
-
*/
|
|
51
|
+
// Uses `any` because Prisma generates highly complex client types that are difficult
|
|
52
|
+
// to constrain here; actual type safety comes from the TPrisma generic parameter.
|
|
59
53
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
54
|
export type PrismaClientLike = any
|
|
61
55
|
|
|
@@ -102,11 +96,9 @@ export type FindManyQueryArgs = {
|
|
|
102
96
|
*/
|
|
103
97
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
104
98
|
export interface AugmentedFindMany<TOriginal extends (...args: any[]) => any> {
|
|
105
|
-
// Overload 1: with query fragment — return type narrows to ResultOf<fragment>[]
|
|
106
99
|
<TItem, TFields extends FieldSelection<TItem>>(
|
|
107
100
|
args: FindManyQueryArgs & { query: Fragment<TItem, TFields> },
|
|
108
101
|
): Promise<ResultOf<Fragment<TItem, TFields>>[]>
|
|
109
|
-
// Overload 2: original Prisma behaviour
|
|
110
102
|
(...args: Parameters<TOriginal>): ReturnType<TOriginal>
|
|
111
103
|
}
|
|
112
104
|
|
|
@@ -144,11 +136,9 @@ export type FindFirstQueryArgs = {
|
|
|
144
136
|
*/
|
|
145
137
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
146
138
|
export interface AugmentedFindFirst<TOriginal extends (...args: any[]) => any> {
|
|
147
|
-
// Overload 1: with query fragment — return type narrows to ResultOf<fragment> | null
|
|
148
139
|
<TItem, TFields extends FieldSelection<TItem>>(
|
|
149
140
|
args: FindFirstQueryArgs & { query: Fragment<TItem, TFields> },
|
|
150
141
|
): Promise<ResultOf<Fragment<TItem, TFields>> | null>
|
|
151
|
-
// Overload 2: original Prisma behaviour
|
|
152
142
|
(...args: Parameters<TOriginal>): ReturnType<TOriginal>
|
|
153
143
|
}
|
|
154
144
|
|
|
@@ -171,23 +161,17 @@ export interface AugmentedFindFirst<TOriginal extends (...args: any[]) => any> {
|
|
|
171
161
|
*/
|
|
172
162
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
173
163
|
export interface AugmentedFindUnique<TOriginal extends (...args: any[]) => any> {
|
|
174
|
-
// Overload 1: with query fragment — return type narrows to ResultOf<fragment> | null
|
|
175
164
|
<TItem, TFields extends FieldSelection<TItem>>(args: {
|
|
176
165
|
where: Record<string, unknown>
|
|
177
166
|
query: Fragment<TItem, TFields>
|
|
178
167
|
}): Promise<ResultOf<Fragment<TItem, TFields>> | null>
|
|
179
|
-
// Overload 2: original Prisma behaviour
|
|
180
168
|
(...args: Parameters<TOriginal>): ReturnType<TOriginal>
|
|
181
169
|
}
|
|
182
170
|
|
|
183
|
-
/**
|
|
184
|
-
* Map Prisma client to access-controlled database context
|
|
185
|
-
* Preserves Prisma's type information for each model
|
|
186
|
-
*/
|
|
187
171
|
export type AccessControlledDB<TPrisma extends PrismaClientLike> = {
|
|
188
172
|
[K in keyof TPrisma]: TPrisma[K] extends {
|
|
189
|
-
// Uses `any`
|
|
190
|
-
//
|
|
173
|
+
// Uses `any` here to check the property exists with any signature, a standard
|
|
174
|
+
// TypeScript pattern for verifying Prisma model shape in a conditional type.
|
|
191
175
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
192
176
|
findUnique: any
|
|
193
177
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -232,17 +216,7 @@ export type AccessControlledDB<TPrisma extends PrismaClientLike> = {
|
|
|
232
216
|
[key: string]: any
|
|
233
217
|
}
|
|
234
218
|
|
|
235
|
-
/**
|
|
236
|
-
* Storage utilities for file/image uploads
|
|
237
|
-
*/
|
|
238
219
|
export type StorageUtils = {
|
|
239
|
-
/**
|
|
240
|
-
* Upload a file to storage
|
|
241
|
-
* @param providerName - Name of storage provider from config
|
|
242
|
-
* @param file - File object
|
|
243
|
-
* @param buffer - File contents as Buffer
|
|
244
|
-
* @param options - Upload options (validation, metadata)
|
|
245
|
-
*/
|
|
246
220
|
uploadFile: (
|
|
247
221
|
providerName: string,
|
|
248
222
|
file: File,
|
|
@@ -250,13 +224,6 @@ export type StorageUtils = {
|
|
|
250
224
|
options?: unknown,
|
|
251
225
|
) => Promise<unknown>
|
|
252
226
|
|
|
253
|
-
/**
|
|
254
|
-
* Upload an image with transformations
|
|
255
|
-
* @param providerName - Name of storage provider from config
|
|
256
|
-
* @param file - File object
|
|
257
|
-
* @param buffer - File contents as Buffer
|
|
258
|
-
* @param options - Upload options (validation, transformations, metadata)
|
|
259
|
-
*/
|
|
260
227
|
uploadImage: (
|
|
261
228
|
providerName: string,
|
|
262
229
|
file: File,
|
|
@@ -264,24 +231,12 @@ export type StorageUtils = {
|
|
|
264
231
|
options?: unknown,
|
|
265
232
|
) => Promise<unknown>
|
|
266
233
|
|
|
267
|
-
/**
|
|
268
|
-
* Delete a file from storage
|
|
269
|
-
* @param providerName - Name of storage provider from config
|
|
270
|
-
* @param filename - Name of file to delete
|
|
271
|
-
*/
|
|
272
234
|
deleteFile: (providerName: string, filename: string) => Promise<void>
|
|
273
235
|
|
|
274
|
-
/**
|
|
275
|
-
* Delete an image and all its transformations
|
|
276
|
-
* @param metadata - Image metadata containing storage provider and filename
|
|
277
|
-
*/
|
|
278
236
|
deleteImage: (metadata: unknown) => Promise<void>
|
|
279
237
|
}
|
|
280
238
|
|
|
281
|
-
|
|
282
|
-
* Context type (simplified for access control)
|
|
283
|
-
* Using interface instead of type to allow module augmentation
|
|
284
|
-
*/
|
|
239
|
+
// Uses `interface` rather than `type` so consumers can extend it via module augmentation.
|
|
285
240
|
export interface AccessContext<TPrisma extends PrismaClientLike = PrismaClientLike> {
|
|
286
241
|
session: Session | null
|
|
287
242
|
prisma: TPrisma
|
|
@@ -303,16 +258,22 @@ export interface AccessContext<TPrisma extends PrismaClientLike = PrismaClientLi
|
|
|
303
258
|
* in CONTEXT.md.
|
|
304
259
|
*/
|
|
305
260
|
_resolveOutputChain: readonly { listKey: string; fieldKey: string }[]
|
|
261
|
+
/**
|
|
262
|
+
* Present when this context is JOINED into an enclosing transaction it did
|
|
263
|
+
* not open (ADR-0028, #899) — set by `context.transaction()`, or by the
|
|
264
|
+
* Write Pipeline when it opens the transaction the current write's hooks
|
|
265
|
+
* are rebound into. A write reached through a context carrying this defers
|
|
266
|
+
* its `afterTransaction` bracket to the registry instead of firing it at
|
|
267
|
+
* write time; `undefined` for a top-level context with no owner. Threaded
|
|
268
|
+
* through the same context-rebind path as `plugins`/`_resolveOutputChain`.
|
|
269
|
+
* @internal
|
|
270
|
+
*/
|
|
271
|
+
_transactionOwner?: TransactionRegistry
|
|
306
272
|
}
|
|
307
273
|
|
|
308
|
-
/**
|
|
309
|
-
* Prisma filter type - represents a where clause
|
|
310
|
-
* Uses Partial to allow filtering by any subset of fields
|
|
311
|
-
*/
|
|
312
274
|
export type PrismaFilter<T = Record<string, unknown>> = Partial<Record<keyof T, unknown>>
|
|
313
275
|
|
|
314
276
|
/**
|
|
315
|
-
* Access control function type
|
|
316
277
|
* Can return:
|
|
317
278
|
* - boolean: true = allow, false = deny
|
|
318
279
|
* - PrismaFilter: Prisma where clause to filter results
|
|
@@ -324,51 +285,87 @@ export type AccessControl<T = Record<string, unknown>> = (args: {
|
|
|
324
285
|
}) => boolean | PrismaFilter<T> | Promise<boolean | PrismaFilter<T>>
|
|
325
286
|
|
|
326
287
|
/**
|
|
327
|
-
*
|
|
288
|
+
* The per-operation argument shapes a `FieldAccessControl` function is called
|
|
289
|
+
* with — the discriminated union `FieldAccessControl` wraps, and the source
|
|
290
|
+
* of truth `FieldAccess`'s individual `read`/`create`/`update` members are
|
|
291
|
+
* picked from (via `Extract`) below. Keeping this as its own named type is
|
|
292
|
+
* what lets both sides reference the exact same three call shapes instead of
|
|
293
|
+
* two independently-maintained descriptions drifting apart.
|
|
294
|
+
*/
|
|
295
|
+
type FieldAccessControlArgs<TItem, TCreateInput, TUpdateInput> =
|
|
296
|
+
| {
|
|
297
|
+
session: Session | null
|
|
298
|
+
// Field Visibility (phase 2 of the two-phase read) always evaluates
|
|
299
|
+
// `read` rules against an already-fetched row — see
|
|
300
|
+
// `resolveReadableFieldValue` in `field-visibility.ts`, the sole
|
|
301
|
+
// caller of `checkFieldAccess` for this operation. Unlike `create`
|
|
302
|
+
// (where no row exists yet), there is no case where `item` is absent
|
|
303
|
+
// here, so it is required rather than optional.
|
|
304
|
+
item: TItem
|
|
305
|
+
context: AccessContext
|
|
306
|
+
inputData?: undefined
|
|
307
|
+
operation: 'read'
|
|
308
|
+
}
|
|
309
|
+
| {
|
|
310
|
+
session: Session | null
|
|
311
|
+
item?: undefined
|
|
312
|
+
context: AccessContext
|
|
313
|
+
inputData: TCreateInput
|
|
314
|
+
operation: 'create'
|
|
315
|
+
}
|
|
316
|
+
| {
|
|
317
|
+
session: Session | null
|
|
318
|
+
item: TItem
|
|
319
|
+
context: AccessContext
|
|
320
|
+
inputData: TUpdateInput
|
|
321
|
+
operation: 'update'
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
328
325
|
* For create/update operations, receives inputData to validate incoming values.
|
|
329
326
|
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
327
|
+
* Unlike operation-level `AccessControl`, this returns `boolean` only. Field
|
|
328
|
+
* access is a per-field visibility decision, not a row filter — a denied
|
|
329
|
+
* field is removed, never used to scope which rows are returned (see the
|
|
330
|
+
* "Field-level access" glossary entry in `CONTEXT.md`, ADR-0001, and
|
|
331
|
+
* ADR-0030). A rule that needs to depend on the row or the write payload
|
|
332
|
+
* should evaluate the condition itself and return a boolean, e.g.
|
|
333
|
+
* `({ item, session }) => item?.ownerId === session?.userId`. The runtime
|
|
334
|
+
* evaluator (`checkFieldAccess`) enforces this: a rule that somehow returns
|
|
335
|
+
* anything other than `true`/`false` (bypassing this type) throws rather than
|
|
336
|
+
* defaulting to allow.
|
|
337
|
+
*
|
|
338
|
+
* This is the general, all-operations union — useful for a single function
|
|
339
|
+
* reused across more than one of `FieldAccess`'s `read`/`create`/`update`
|
|
340
|
+
* slots, narrowing on `operation` to tell the call shapes apart. A function
|
|
341
|
+
* written for exactly one slot doesn't need to: `FieldAccess` picks each
|
|
342
|
+
* slot's own single-operation shape out of this union (see below), so e.g. a
|
|
343
|
+
* `read`-only rule sees `item` as always present with no narrowing required.
|
|
333
344
|
*/
|
|
334
345
|
export type FieldAccessControl<
|
|
335
346
|
TItem = Record<string, unknown>,
|
|
336
347
|
TCreateInput = Record<string, unknown>,
|
|
337
348
|
TUpdateInput = Record<string, unknown>,
|
|
338
|
-
> = (
|
|
339
|
-
args:
|
|
340
|
-
| {
|
|
341
|
-
session: Session | null
|
|
342
|
-
item?: undefined
|
|
343
|
-
context: AccessContext
|
|
344
|
-
inputData?: undefined
|
|
345
|
-
operation: 'read'
|
|
346
|
-
}
|
|
347
|
-
| {
|
|
348
|
-
session: Session | null
|
|
349
|
-
item?: undefined
|
|
350
|
-
context: AccessContext
|
|
351
|
-
inputData: TCreateInput
|
|
352
|
-
operation: 'create'
|
|
353
|
-
}
|
|
354
|
-
| {
|
|
355
|
-
session: Session | null
|
|
356
|
-
item: TItem
|
|
357
|
-
context: AccessContext
|
|
358
|
-
inputData: TUpdateInput
|
|
359
|
-
operation: 'update'
|
|
360
|
-
},
|
|
361
|
-
) => boolean | PrismaFilter<TItem> | Promise<boolean | PrismaFilter<TItem>>
|
|
349
|
+
> = (args: FieldAccessControlArgs<TItem, TCreateInput, TUpdateInput>) => boolean | Promise<boolean>
|
|
362
350
|
|
|
363
351
|
/**
|
|
364
|
-
*
|
|
352
|
+
* `read` is typed from the single `operation: 'read'` member of
|
|
353
|
+
* `FieldAccessControlArgs`, not the full `FieldAccessControl` union — so a
|
|
354
|
+
* rule written directly for this slot sees `item` as always present (never
|
|
355
|
+
* `TItem | undefined`) with no narrowing, cast, or `any` needed. A function
|
|
356
|
+
* typed as the broader `FieldAccessControl` union remains assignable here
|
|
357
|
+
* (and to `create`/`update`) — accepting more call shapes than the slot
|
|
358
|
+
* requires is a valid substitute, same as anywhere else function parameters
|
|
359
|
+
* are contravariant.
|
|
365
360
|
*/
|
|
366
361
|
export type FieldAccess<
|
|
367
362
|
TItem = Record<string, unknown>,
|
|
368
363
|
TCreateInput = Record<string, unknown>,
|
|
369
364
|
TUpdateInput = Record<string, unknown>,
|
|
370
365
|
> = {
|
|
371
|
-
read?:
|
|
366
|
+
read?: (
|
|
367
|
+
args: Extract<FieldAccessControlArgs<TItem, TCreateInput, TUpdateInput>, { operation: 'read' }>,
|
|
368
|
+
) => boolean | Promise<boolean>
|
|
372
369
|
create?: FieldAccessControl<TItem, TCreateInput, TUpdateInput>
|
|
373
370
|
update?: FieldAccessControl<TItem, TCreateInput, TUpdateInput>
|
|
374
371
|
}
|
package/src/config/index.ts
CHANGED
|
@@ -8,16 +8,11 @@ import type {
|
|
|
8
8
|
import { executePlugins } from './plugin-engine.js'
|
|
9
9
|
import type { AccessControl } from '../access/types.js'
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* Normalize access control shorthand to object form
|
|
13
|
-
* Converts function shorthand to { operation: { query, create, update, delete } } form
|
|
14
|
-
*/
|
|
15
11
|
function normalizeListAccess<T>(
|
|
16
12
|
access: ListAccessControl<T> | undefined,
|
|
17
13
|
): { operation?: OperationAccess<T> } | undefined {
|
|
18
14
|
if (!access) return undefined
|
|
19
15
|
|
|
20
|
-
// If it's a function, convert to object form applying to all operations
|
|
21
16
|
if (typeof access === 'function') {
|
|
22
17
|
const fn = access as AccessControl<T>
|
|
23
18
|
return {
|
|
@@ -30,32 +25,31 @@ function normalizeListAccess<T>(
|
|
|
30
25
|
}
|
|
31
26
|
}
|
|
32
27
|
|
|
33
|
-
// Already in object form
|
|
34
28
|
return access
|
|
35
29
|
}
|
|
36
30
|
|
|
37
31
|
/**
|
|
38
|
-
*
|
|
39
|
-
* Executes plugins if present in config.plugins array
|
|
32
|
+
* Define an OpenSaas config, executing `plugins` if present.
|
|
40
33
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
34
|
+
* Returns synchronously when there are no plugins (backward compatible);
|
|
35
|
+
* returns a `Promise` when plugins are present, since plugin execution is
|
|
36
|
+
* async.
|
|
43
37
|
*/
|
|
44
38
|
export function config(userConfig: OpenSaasConfig): OpenSaasConfig | Promise<OpenSaasConfig> {
|
|
45
|
-
// If no plugins, return config as-is (synchronous, backward compatible)
|
|
46
39
|
if (!userConfig.plugins || userConfig.plugins.length === 0) {
|
|
47
40
|
return userConfig
|
|
48
41
|
}
|
|
49
42
|
|
|
50
|
-
// Execute plugins and return promise
|
|
51
43
|
return executePlugins(userConfig)
|
|
52
44
|
}
|
|
53
45
|
|
|
54
46
|
/**
|
|
55
|
-
*
|
|
47
|
+
* Define a list with type safety.
|
|
56
48
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
49
|
+
* `ListConfigInput<TTypeInfo>` accepts raw field configs; the return type
|
|
50
|
+
* `ListConfig<TTypeInfo>` injects `TTypeInfo` so hook/field callbacks (e.g.
|
|
51
|
+
* `resolveInput`'s `item`) are properly typed once `TTypeInfo` is supplied
|
|
52
|
+
* from generated types.
|
|
59
53
|
*
|
|
60
54
|
* @example
|
|
61
55
|
* ```typescript
|
|
@@ -108,18 +102,15 @@ export function config(userConfig: OpenSaasConfig): OpenSaasConfig | Promise<Ope
|
|
|
108
102
|
export function list<TTypeInfo extends import('./types.js').TypeInfo>(
|
|
109
103
|
config: ListConfigInput<TTypeInfo>,
|
|
110
104
|
): ListConfig<TTypeInfo> {
|
|
111
|
-
// Normalize access control shorthand to object form
|
|
112
105
|
const normalizedConfig = {
|
|
113
106
|
...config,
|
|
114
107
|
access: normalizeListAccess(config.access),
|
|
115
108
|
}
|
|
116
109
|
|
|
117
|
-
//
|
|
118
|
-
// At type level, they're transformed to inject TypeInfo types
|
|
110
|
+
// Runtime shape is unchanged; the cast only narrows the TS type to inject TTypeInfo.
|
|
119
111
|
return normalizedConfig as ListConfig<TTypeInfo>
|
|
120
112
|
}
|
|
121
113
|
|
|
122
|
-
// Re-export all types
|
|
123
114
|
export type {
|
|
124
115
|
OpenSaasConfig,
|
|
125
116
|
OutputConfig,
|
|
@@ -171,16 +162,13 @@ export type {
|
|
|
171
162
|
FileMetadata,
|
|
172
163
|
ImageMetadata,
|
|
173
164
|
ImageTransformationResult,
|
|
174
|
-
// Plugin system types
|
|
175
165
|
Plugin,
|
|
176
166
|
PluginContext,
|
|
177
167
|
GeneratedFiles,
|
|
178
|
-
// List-level hook argument types
|
|
179
168
|
ResolveInputHookArgs,
|
|
180
169
|
ValidateHookArgs,
|
|
181
170
|
BeforeOperationHookArgs,
|
|
182
171
|
AfterOperationHookArgs,
|
|
183
|
-
// Field-level hook argument types
|
|
184
172
|
FieldResolveInputHookArgs,
|
|
185
173
|
FieldValidateHookArgs,
|
|
186
174
|
FieldBeforeOperationHookArgs,
|
package/src/config/label.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import type { ListConfig } from './types.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Resolve the field name
|
|
5
|
-
*
|
|
6
|
-
* field this returns, so the field chosen for projection can never drift
|
|
7
|
-
* from the field used for rendering.
|
|
4
|
+
* Resolve the field name used as a list's Label field: `ui.labelField` if
|
|
5
|
+
* set, else `name`, else `title`, else `id`.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
* (
|
|
11
|
-
*
|
|
12
|
-
* @throws if `ui.labelField` is set but does not reference a declared,
|
|
13
|
-
* non-relationship field on the list.
|
|
7
|
+
* @throws if `ui.labelField` names a field not declared on the list, or a
|
|
8
|
+
* relationship field (the Label field must be a scalar).
|
|
14
9
|
*/
|
|
15
10
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
16
11
|
export function getLabelFieldName(listConfig: ListConfig<any>): string {
|
|
@@ -37,10 +32,8 @@ export function getLabelFieldName(listConfig: ListConfig<any>): string {
|
|
|
37
32
|
}
|
|
38
33
|
|
|
39
34
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* when that field is missing from the row (e.g. stripped by field-level
|
|
43
|
-
* access).
|
|
35
|
+
* Render a row's Label field ({@link getLabelFieldName}) as text, falling
|
|
36
|
+
* back to `item.id` when the value is `null`/`undefined`.
|
|
44
37
|
*/
|
|
45
38
|
export function getItemLabel(
|
|
46
39
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|