@opensaas/stack-core 0.33.0 → 0.35.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 +82 -0
- package/CLAUDE.md +4 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +12 -7
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +5 -1
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/depth-limits.d.ts +14 -0
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +14 -0
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/errors.d.ts +24 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +26 -0
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +72 -17
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +1 -0
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +2 -0
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.test.js +1 -1
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +1 -1
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/types.d.ts +15 -7
- package/dist/access/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +73 -36
- package/dist/context/index.js.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +5 -4
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +5 -1
- package/src/access/access-filter.ts +12 -7
- package/src/access/depth-limits.ts +15 -0
- package/src/access/errors.ts +30 -0
- package/src/access/field-visibility.ts +87 -18
- package/src/access/index.ts +2 -0
- package/src/access/multi-column-read-write.test.ts +1 -1
- package/src/access/relationship-count.test.ts +1 -1
- package/src/access/relationship-label-filter.test.ts +1 -1
- package/src/access/types.ts +12 -5
- package/src/context/index.ts +99 -53
- package/src/context/write-pipeline.ts +5 -4
- package/src/index.ts +5 -0
- package/tests/access-relationships.test.ts +1 -1
- package/tests/bare-read-scalars.test.ts +147 -0
- package/tests/context.test.ts +2 -2
- package/tests/default-value-create.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +1 -1
- package/tests/nav-count.test.ts +2 -2
- package/tests/nested-access-and-hooks.test.ts +10 -0
- package/tests/resolve-chain.test.ts +411 -0
- package/tests/singleton.test.ts +83 -0
- package/tests/write-pipeline.test.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -297,9 +297,10 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
|
|
|
297
297
|
* construction, so the request-time `context.db` is bound to the ORIGINAL
|
|
298
298
|
* client. We rebuild the delegates against `tx` via {@link buildDbDelegate},
|
|
299
299
|
* reusing the request context's `session`, `storage`, `plugins`, `_isSudo`, and
|
|
300
|
-
* the
|
|
301
|
-
*
|
|
302
|
-
* `plugins` object is
|
|
300
|
+
* the current `_resolveOutputChain` value (carried through unchanged, so a
|
|
301
|
+
* write issued from inside a `resolveOutput` hook keeps that hook's chain).
|
|
302
|
+
* Plugin runtimes are NOT re-executed; the existing `plugins` object is
|
|
303
|
+
* reused as-is.
|
|
303
304
|
*/
|
|
304
305
|
function bindContextToTransaction<TPrisma extends PrismaClientLike>(
|
|
305
306
|
args: WritePipelineArgs<TPrisma>,
|
|
@@ -313,7 +314,7 @@ function bindContextToTransaction<TPrisma extends PrismaClientLike>(
|
|
|
313
314
|
storage: context.storage,
|
|
314
315
|
plugins: context.plugins,
|
|
315
316
|
_isSudo: context._isSudo,
|
|
316
|
-
|
|
317
|
+
_resolveOutputChain: context._resolveOutputChain,
|
|
317
318
|
}
|
|
318
319
|
// Rebuild the db delegate against `tx`, pointing back at `txContext` so hooks
|
|
319
320
|
// reached through it also see the transactional context.
|
package/src/index.ts
CHANGED
|
@@ -68,6 +68,11 @@ export { ValidationError } from './hooks/index.js'
|
|
|
68
68
|
// a user-input validation failure.
|
|
69
69
|
export { AccessScopeDepthExceededError } from './access/index.js'
|
|
70
70
|
|
|
71
|
+
// Thrown by a `resolveOutput` hook whose own read cycles back into a
|
|
72
|
+
// `(list, field)` pair already on its resolve chain (see ADR-0023). Distinct
|
|
73
|
+
// from `ValidationError` for the same reason as `AccessScopeDepthExceededError`.
|
|
74
|
+
export { ResolveOutputCycleError } from './access/index.js'
|
|
75
|
+
|
|
71
76
|
// Field self-containment validation — checks each field implements the
|
|
72
77
|
// generation contract (getPrismaType / getTypeScriptType / getZodSchema, or
|
|
73
78
|
// getPrismaRelation for relationships) so a misimplemented field fails early
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import { config, list } from '../src/config/index.js'
|
|
4
|
+
import { text, relationship, virtual } from '../src/fields/index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression coverage for issue #848 / ADR-0024: a `context.db` read with no
|
|
8
|
+
* `include` (and no fragment `query`) used to auto-include every readable
|
|
9
|
+
* relationship of the list, recursing to `READ_INCLUDE_MAX_DEPTH` and
|
|
10
|
+
* evaluating every related list's operation-level `query` access along the
|
|
11
|
+
* way. It now returns the row's own columns plus its virtual fields — nothing
|
|
12
|
+
* more — matching Prisma's own semantics for a bare read. Relations are
|
|
13
|
+
* fetched only when a caller names them via `include` (covered, unmodified,
|
|
14
|
+
* by the #566/#830 regression suites in `context.test.ts`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
18
|
+
function createMockPrisma(): any {
|
|
19
|
+
return {
|
|
20
|
+
author: {
|
|
21
|
+
findFirst: vi.fn(),
|
|
22
|
+
findMany: vi.fn(),
|
|
23
|
+
create: vi.fn(),
|
|
24
|
+
update: vi.fn(),
|
|
25
|
+
delete: vi.fn(),
|
|
26
|
+
count: vi.fn(),
|
|
27
|
+
},
|
|
28
|
+
post: {
|
|
29
|
+
findFirst: vi.fn(),
|
|
30
|
+
findMany: vi.fn(),
|
|
31
|
+
create: vi.fn(),
|
|
32
|
+
update: vi.fn(),
|
|
33
|
+
delete: vi.fn(),
|
|
34
|
+
count: vi.fn(),
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function buildTestConfig(authorQuerySpy: ReturnType<typeof vi.fn>) {
|
|
40
|
+
return config({
|
|
41
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
42
|
+
lists: {
|
|
43
|
+
Author: list({
|
|
44
|
+
fields: {
|
|
45
|
+
name: text(),
|
|
46
|
+
},
|
|
47
|
+
access: { operation: { query: authorQuerySpy } },
|
|
48
|
+
}),
|
|
49
|
+
Post: list({
|
|
50
|
+
fields: {
|
|
51
|
+
title: text(),
|
|
52
|
+
// List-only ref keeps the schema minimal — the relation still owns
|
|
53
|
+
// an `authorId` foreign-key column, which is what matters here.
|
|
54
|
+
author: relationship({ ref: 'Author' }),
|
|
55
|
+
shout: virtual({
|
|
56
|
+
type: 'string',
|
|
57
|
+
hooks: {
|
|
58
|
+
resolveOutput: ({ item }) => `${(item as { title?: string }).title}!`,
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
access: { operation: { query: () => true } },
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('a bare read fetches scalars, not relations (#848, ADR-0024)', () => {
|
|
69
|
+
it('findUnique with no include sends no include to the ORM and evaluates no related query access', async () => {
|
|
70
|
+
const authorQuerySpy = vi.fn(() => true)
|
|
71
|
+
const testConfig = await buildTestConfig(authorQuerySpy)
|
|
72
|
+
const mockPrisma = createMockPrisma()
|
|
73
|
+
mockPrisma.post.findFirst.mockResolvedValue({ id: '1', title: 'Hello', authorId: 'a1' })
|
|
74
|
+
|
|
75
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
76
|
+
const result = await context.db.post.findUnique({ where: { id: '1' } })
|
|
77
|
+
|
|
78
|
+
expect(mockPrisma.post.findFirst).toHaveBeenCalledWith({
|
|
79
|
+
where: { id: '1' },
|
|
80
|
+
include: undefined,
|
|
81
|
+
})
|
|
82
|
+
// Related list's operation-level `query` access is never invoked — a bare
|
|
83
|
+
// read never even considers whether the relation is fetchable.
|
|
84
|
+
expect(authorQuerySpy).not.toHaveBeenCalled()
|
|
85
|
+
|
|
86
|
+
// Own columns (including the FK column) and the computed virtual field
|
|
87
|
+
// are present; the relation key is absent entirely.
|
|
88
|
+
expect(result).toEqual({ id: '1', title: 'Hello', authorId: 'a1', shout: 'Hello!' })
|
|
89
|
+
expect(result).not.toHaveProperty('author')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('findMany with no include sends no include to the ORM and evaluates no related query access', async () => {
|
|
93
|
+
const authorQuerySpy = vi.fn(() => true)
|
|
94
|
+
const testConfig = await buildTestConfig(authorQuerySpy)
|
|
95
|
+
const mockPrisma = createMockPrisma()
|
|
96
|
+
mockPrisma.post.findMany.mockResolvedValue([{ id: '1', title: 'Hello', authorId: 'a1' }])
|
|
97
|
+
|
|
98
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
99
|
+
const result = await context.db.post.findMany({})
|
|
100
|
+
|
|
101
|
+
expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
|
|
102
|
+
expect.objectContaining({ include: undefined }),
|
|
103
|
+
)
|
|
104
|
+
expect(authorQuerySpy).not.toHaveBeenCalled()
|
|
105
|
+
|
|
106
|
+
expect(result).toEqual([{ id: '1', title: 'Hello', authorId: 'a1', shout: 'Hello!' }])
|
|
107
|
+
expect(result[0]).not.toHaveProperty('author')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('a caller-supplied include still evaluates the related query access (unchanged)', async () => {
|
|
111
|
+
const authorQuerySpy = vi.fn(() => true)
|
|
112
|
+
const testConfig = await buildTestConfig(authorQuerySpy)
|
|
113
|
+
const mockPrisma = createMockPrisma()
|
|
114
|
+
mockPrisma.post.findFirst.mockResolvedValue({
|
|
115
|
+
id: '1',
|
|
116
|
+
title: 'Hello',
|
|
117
|
+
authorId: 'a1',
|
|
118
|
+
author: { id: 'a1', name: 'Ann' },
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const context = getContext(testConfig, mockPrisma, null)
|
|
122
|
+
const result = await context.db.post.findUnique({
|
|
123
|
+
where: { id: '1' },
|
|
124
|
+
include: { author: true },
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(authorQuerySpy).toHaveBeenCalled()
|
|
128
|
+
expect(result?.author).toEqual({ id: 'a1', name: 'Ann' })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('sudo bare read also sends no include (behaviour already matched Prisma; unaffected by this change)', async () => {
|
|
132
|
+
const authorQuerySpy = vi.fn(() => true)
|
|
133
|
+
const testConfig = await buildTestConfig(authorQuerySpy)
|
|
134
|
+
const mockPrisma = createMockPrisma()
|
|
135
|
+
mockPrisma.post.findFirst.mockResolvedValue({ id: '1', title: 'Hello', authorId: 'a1' })
|
|
136
|
+
|
|
137
|
+
const context = getContext(testConfig, mockPrisma, null).sudo()
|
|
138
|
+
const result = await context.db.post.findUnique({ where: { id: '1' } })
|
|
139
|
+
|
|
140
|
+
expect(mockPrisma.post.findFirst).toHaveBeenCalledWith({
|
|
141
|
+
where: { id: '1' },
|
|
142
|
+
include: undefined,
|
|
143
|
+
})
|
|
144
|
+
expect(authorQuerySpy).not.toHaveBeenCalled()
|
|
145
|
+
expect(result).toEqual({ id: '1', title: 'Hello', authorId: 'a1', shout: 'Hello!' })
|
|
146
|
+
})
|
|
147
|
+
})
|
package/tests/context.test.ts
CHANGED
|
@@ -1430,7 +1430,7 @@ describe('getContext', () => {
|
|
|
1430
1430
|
// Regression for issue #830: a read issued from inside a `resolveOutput`
|
|
1431
1431
|
// hook used to lose relation row scoping ENTIRELY — `buildIncludeWithAccessControl`
|
|
1432
1432
|
// returned a whole-object `undefined` for the inner read (any
|
|
1433
|
-
// `
|
|
1433
|
+
// `_resolveOutputChain.length > 0`), which `mergeIncludeWithAccessControl`
|
|
1434
1434
|
// treated as "nothing to merge against" and passed the caller's include
|
|
1435
1435
|
// through completely unscoped. The fix scopes each immediate relation with
|
|
1436
1436
|
// its own access `where` while still not auto-EXPANDING into that
|
|
@@ -1439,7 +1439,7 @@ describe('getContext', () => {
|
|
|
1439
1439
|
describe('scopes (without expanding) a caller include used inside a resolveOutput hook (#830)', () => {
|
|
1440
1440
|
// Build an Author config with a virtual field whose resolveOutput issues a
|
|
1441
1441
|
// read WITH an explicit include. While that hook runs,
|
|
1442
|
-
//
|
|
1442
|
+
// _resolveOutputChain.length > 0.
|
|
1443
1443
|
function configWithResolveOutputProbe(
|
|
1444
1444
|
callerInclude: Record<string, unknown>,
|
|
1445
1445
|
capture: (include: unknown) => void,
|
package/tests/nav-count.test.ts
CHANGED
|
@@ -43,7 +43,7 @@ function makeContext(counts: Record<string, number>): {
|
|
|
43
43
|
storage: {},
|
|
44
44
|
plugins: {},
|
|
45
45
|
_isSudo: false,
|
|
46
|
-
|
|
46
|
+
_resolveOutputChain: [],
|
|
47
47
|
} as unknown as AccessContext
|
|
48
48
|
return { context, spies }
|
|
49
49
|
}
|
|
@@ -168,7 +168,7 @@ describe('resolveNavCounts', () => {
|
|
|
168
168
|
storage: {},
|
|
169
169
|
plugins: {},
|
|
170
170
|
_isSudo: false,
|
|
171
|
-
|
|
171
|
+
_resolveOutputChain: [],
|
|
172
172
|
} as unknown as AccessContext
|
|
173
173
|
|
|
174
174
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
@@ -397,8 +397,12 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
397
397
|
|
|
398
398
|
const context = getContext(await testConfig, mockPrisma, null)
|
|
399
399
|
|
|
400
|
+
// A caller-supplied `include` is required to fetch `posts` at all — a
|
|
401
|
+
// bare read returns scalars only (ADR-0024) — so name it explicitly to
|
|
402
|
+
// exercise the per-relation access `where` merge.
|
|
400
403
|
await context.db.user.findUnique({
|
|
401
404
|
where: { id: '1' },
|
|
405
|
+
include: { posts: true },
|
|
402
406
|
})
|
|
403
407
|
|
|
404
408
|
// Verify findFirst was called with access filter
|
|
@@ -469,8 +473,11 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
469
473
|
|
|
470
474
|
const context = getContext(await testConfig, mockPrisma, null)
|
|
471
475
|
|
|
476
|
+
// `author` must be named explicitly — a bare read returns scalars only
|
|
477
|
+
// (ADR-0024).
|
|
472
478
|
const result = await context.db.post.findUnique({
|
|
473
479
|
where: { id: '1' },
|
|
480
|
+
include: { author: true },
|
|
474
481
|
})
|
|
475
482
|
|
|
476
483
|
// Email should be filtered out
|
|
@@ -522,8 +529,11 @@ describe('Nested Operations - Access Control and Hooks', () => {
|
|
|
522
529
|
|
|
523
530
|
const context = getContext(await testConfig, mockPrisma, null)
|
|
524
531
|
|
|
532
|
+
// `author` must be named explicitly to exercise the access-denied drop —
|
|
533
|
+
// a bare read never requests it at all (ADR-0024).
|
|
525
534
|
await context.db.post.findUnique({
|
|
526
535
|
where: { id: '1' },
|
|
536
|
+
include: { author: true },
|
|
527
537
|
})
|
|
528
538
|
|
|
529
539
|
// Verify include does NOT include author (access denied)
|