@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.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +82 -0
  3. package/CLAUDE.md +4 -0
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +12 -7
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.js +5 -1
  8. package/dist/access/access-filter.test.js.map +1 -1
  9. package/dist/access/depth-limits.d.ts +14 -0
  10. package/dist/access/depth-limits.d.ts.map +1 -1
  11. package/dist/access/depth-limits.js +14 -0
  12. package/dist/access/depth-limits.js.map +1 -1
  13. package/dist/access/errors.d.ts +24 -0
  14. package/dist/access/errors.d.ts.map +1 -1
  15. package/dist/access/errors.js +26 -0
  16. package/dist/access/errors.js.map +1 -1
  17. package/dist/access/field-visibility.d.ts.map +1 -1
  18. package/dist/access/field-visibility.js +72 -17
  19. package/dist/access/field-visibility.js.map +1 -1
  20. package/dist/access/index.d.ts +1 -0
  21. package/dist/access/index.d.ts.map +1 -1
  22. package/dist/access/index.js +2 -0
  23. package/dist/access/index.js.map +1 -1
  24. package/dist/access/multi-column-read-write.test.js +1 -1
  25. package/dist/access/multi-column-read-write.test.js.map +1 -1
  26. package/dist/access/relationship-count.test.js +1 -1
  27. package/dist/access/relationship-count.test.js.map +1 -1
  28. package/dist/access/relationship-label-filter.test.js +1 -1
  29. package/dist/access/relationship-label-filter.test.js.map +1 -1
  30. package/dist/access/types.d.ts +15 -7
  31. package/dist/access/types.d.ts.map +1 -1
  32. package/dist/context/index.d.ts.map +1 -1
  33. package/dist/context/index.js +73 -36
  34. package/dist/context/index.js.map +1 -1
  35. package/dist/context/write-pipeline.d.ts.map +1 -1
  36. package/dist/context/write-pipeline.js +5 -4
  37. package/dist/context/write-pipeline.js.map +1 -1
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -0
  41. package/dist/index.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/access/access-filter.test.ts +5 -1
  44. package/src/access/access-filter.ts +12 -7
  45. package/src/access/depth-limits.ts +15 -0
  46. package/src/access/errors.ts +30 -0
  47. package/src/access/field-visibility.ts +87 -18
  48. package/src/access/index.ts +2 -0
  49. package/src/access/multi-column-read-write.test.ts +1 -1
  50. package/src/access/relationship-count.test.ts +1 -1
  51. package/src/access/relationship-label-filter.test.ts +1 -1
  52. package/src/access/types.ts +12 -5
  53. package/src/context/index.ts +99 -53
  54. package/src/context/write-pipeline.ts +5 -4
  55. package/src/index.ts +5 -0
  56. package/tests/access-relationships.test.ts +1 -1
  57. package/tests/bare-read-scalars.test.ts +147 -0
  58. package/tests/context.test.ts +2 -2
  59. package/tests/default-value-create.test.ts +1 -1
  60. package/tests/hook-pipeline.test.ts +1 -1
  61. package/tests/nav-count.test.ts +2 -2
  62. package/tests/nested-access-and-hooks.test.ts +10 -0
  63. package/tests/resolve-chain.test.ts +411 -0
  64. package/tests/singleton.test.ts +83 -0
  65. package/tests/write-pipeline.test.ts +1 -1
  66. 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 shared `_resolveOutputCounter` reference (so resolveOutput depth tracking
301
- * is preserved). Plugin runtimes are NOT re-executed; the existing
302
- * `plugins` object is reused as-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
- _resolveOutputCounter: context._resolveOutputCounter,
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
@@ -23,7 +23,7 @@ describe('Relationship Access Control', () => {
23
23
  },
24
24
  plugins: {},
25
25
  _isSudo: false,
26
- _resolveOutputCounter: { depth: 0 },
26
+ _resolveOutputChain: [],
27
27
  }
28
28
 
29
29
  describe('getRelatedListConfig', () => {
@@ -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
+ })
@@ -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
- // `_resolveOutputCounter.depth > 0`), which `mergeIncludeWithAccessControl`
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
- // _resolveOutputCounter.depth > 0.
1442
+ // _resolveOutputChain.length > 0.
1443
1443
  function configWithResolveOutputProbe(
1444
1444
  callerInclude: Record<string, unknown>,
1445
1445
  capture: (include: unknown) => void,
@@ -33,7 +33,7 @@ function makeContext(): AccessContext {
33
33
  storage: {} as any,
34
34
  plugins: {},
35
35
  _isSudo: false,
36
- _resolveOutputCounter: { depth: 0 },
36
+ _resolveOutputChain: [],
37
37
  }
38
38
  }
39
39
 
@@ -37,7 +37,7 @@ function makeContext(): AccessContext {
37
37
  storage: {} as any,
38
38
  plugins: {},
39
39
  _isSudo: false,
40
- _resolveOutputCounter: { depth: 0 },
40
+ _resolveOutputChain: [],
41
41
  }
42
42
  }
43
43
 
@@ -43,7 +43,7 @@ function makeContext(counts: Record<string, number>): {
43
43
  storage: {},
44
44
  plugins: {},
45
45
  _isSudo: false,
46
- _resolveOutputCounter: { depth: 0 },
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
- _resolveOutputCounter: { depth: 0 },
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)