@opensaas/stack-core 0.39.1 → 0.40.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 (130) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +156 -0
  3. package/CLAUDE.md +51 -0
  4. package/dist/access/access-filter.d.ts +83 -10
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +139 -18
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +224 -12
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/engine.d.ts +43 -1
  11. package/dist/access/engine.d.ts.map +1 -1
  12. package/dist/access/engine.js +58 -0
  13. package/dist/access/engine.js.map +1 -1
  14. package/dist/access/engine.test.d.ts +2 -0
  15. package/dist/access/engine.test.d.ts.map +1 -0
  16. package/dist/access/engine.test.js +56 -0
  17. package/dist/access/engine.test.js.map +1 -0
  18. package/dist/access/errors.d.ts +28 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +38 -2
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-access.d.ts +12 -0
  23. package/dist/access/field-access.d.ts.map +1 -1
  24. package/dist/access/field-access.js +22 -2
  25. package/dist/access/field-access.js.map +1 -1
  26. package/dist/access/field-access.test.js +66 -0
  27. package/dist/access/field-access.test.js.map +1 -1
  28. package/dist/access/field-visibility.d.ts +2 -1
  29. package/dist/access/field-visibility.d.ts.map +1 -1
  30. package/dist/access/field-visibility.js +45 -3
  31. package/dist/access/field-visibility.js.map +1 -1
  32. package/dist/access/index.d.ts +5 -2
  33. package/dist/access/index.d.ts.map +1 -1
  34. package/dist/access/index.js +4 -2
  35. package/dist/access/index.js.map +1 -1
  36. package/dist/config/types.d.ts +77 -11
  37. package/dist/config/types.d.ts.map +1 -1
  38. package/dist/context/index.d.ts +19 -1
  39. package/dist/context/index.d.ts.map +1 -1
  40. package/dist/context/index.js +61 -17
  41. package/dist/context/index.js.map +1 -1
  42. package/dist/context/nested-operations.d.ts.map +1 -1
  43. package/dist/context/nested-operations.js +54 -13
  44. package/dist/context/nested-operations.js.map +1 -1
  45. package/dist/context/write-pipeline.d.ts +3 -2
  46. package/dist/context/write-pipeline.d.ts.map +1 -1
  47. package/dist/context/write-pipeline.js +8 -5
  48. package/dist/context/write-pipeline.js.map +1 -1
  49. package/dist/fields/index.d.ts +13 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +50 -5
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  54. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  55. package/dist/fields/relationship-foreign-key.test.js +18 -0
  56. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  57. package/dist/index.d.ts +4 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +14 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/lib/case-utils.d.ts +15 -0
  62. package/dist/lib/case-utils.d.ts.map +1 -1
  63. package/dist/lib/case-utils.js +21 -0
  64. package/dist/lib/case-utils.js.map +1 -1
  65. package/dist/lib/case-utils.test.js +23 -1
  66. package/dist/lib/case-utils.test.js.map +1 -1
  67. package/dist/lib/prisma-errors.d.ts +38 -0
  68. package/dist/lib/prisma-errors.d.ts.map +1 -0
  69. package/dist/lib/prisma-errors.js +53 -0
  70. package/dist/lib/prisma-errors.js.map +1 -0
  71. package/dist/mcp/constants.d.ts +23 -0
  72. package/dist/mcp/constants.d.ts.map +1 -0
  73. package/dist/mcp/constants.js +23 -0
  74. package/dist/mcp/constants.js.map +1 -0
  75. package/dist/mcp/field-schema.d.ts +8 -0
  76. package/dist/mcp/field-schema.d.ts.map +1 -0
  77. package/dist/mcp/field-schema.js +69 -0
  78. package/dist/mcp/field-schema.js.map +1 -0
  79. package/dist/mcp/handler.d.ts.map +1 -1
  80. package/dist/mcp/handler.js +76 -90
  81. package/dist/mcp/handler.js.map +1 -1
  82. package/dist/mcp/projection.d.ts +62 -0
  83. package/dist/mcp/projection.d.ts.map +1 -0
  84. package/dist/mcp/projection.js +390 -0
  85. package/dist/mcp/projection.js.map +1 -0
  86. package/dist/utils/password.d.ts +3 -1
  87. package/dist/utils/password.d.ts.map +1 -1
  88. package/dist/utils/password.js +4 -2
  89. package/dist/utils/password.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/access/access-filter.test.ts +278 -12
  92. package/src/access/access-filter.ts +207 -19
  93. package/src/access/engine.test.ts +67 -0
  94. package/src/access/engine.ts +81 -1
  95. package/src/access/errors.ts +42 -2
  96. package/src/access/field-access.test.ts +78 -0
  97. package/src/access/field-access.ts +34 -2
  98. package/src/access/field-visibility.ts +75 -10
  99. package/src/access/index.ts +14 -0
  100. package/src/config/types.ts +77 -11
  101. package/src/context/index.ts +95 -15
  102. package/src/context/nested-operations.ts +61 -16
  103. package/src/context/write-pipeline.ts +8 -4
  104. package/src/fields/index.ts +55 -5
  105. package/src/fields/relationship-foreign-key.test.ts +29 -0
  106. package/src/index.ts +17 -1
  107. package/src/lib/case-utils.test.ts +32 -0
  108. package/src/lib/case-utils.ts +26 -0
  109. package/src/lib/prisma-errors.ts +86 -0
  110. package/src/mcp/constants.ts +24 -0
  111. package/src/mcp/field-schema.ts +84 -0
  112. package/src/mcp/handler.ts +109 -107
  113. package/src/mcp/projection.ts +497 -0
  114. package/src/utils/password.ts +5 -3
  115. package/tests/access-relationships.test.ts +3 -3
  116. package/tests/access.test.ts +83 -0
  117. package/tests/context.test.ts +259 -12
  118. package/tests/field-types.test.ts +113 -0
  119. package/tests/mcp-fields-projection-access.test.ts +250 -0
  120. package/tests/mcp-handler.test.ts +695 -4
  121. package/tests/nested-access-and-hooks.test.ts +65 -0
  122. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  123. package/tests/password-types.test.ts +28 -0
  124. package/tests/password.test.ts +16 -2
  125. package/tests/prisma-errors.test.ts +141 -0
  126. package/tests/singleton.test.ts +9 -1
  127. package/tests/to-one-include-access-filter.test.ts +172 -0
  128. package/tests/with-session.test.ts +243 -0
  129. package/tests/write-pipeline.test.ts +29 -0
  130. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest'
2
- import { buildAccessScopedInclude } from './access-filter.js'
2
+ import { buildAccessScopedInclude, resolveToOneAccessVisibility } from './access-filter.js'
3
3
  import { AccessScopeDepthExceededError } from './errors.js'
4
4
  import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
5
5
  import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
@@ -22,6 +22,14 @@ import type { AccessContext } from './types.js'
22
22
  * relation the request doesn't name never has its list's `query` access
23
23
  * invoked, and naming a relation fetches its own columns and stops (the "One
24
24
  * hop" rule) at every level, not just the root.
25
+ *
26
+ * The scenarios in this file build every chain relation as to-MANY
27
+ * (`rel(ref, true)`), so `where`-in-`include` keeps meaning what it always
28
+ * has here — the walk-depth/caller-directed logic under test is arity-
29
+ * agnostic. The to-one-specific behavior this function grew for issue #974
30
+ * (a `where` on a to-one include is not something Prisma accepts, so it's
31
+ * recorded in `toOneAccessFilters` instead and resolved post-query by
32
+ * `resolveToOneAccessVisibility`) has its own dedicated describe blocks below.
25
33
  */
26
34
 
27
35
  // A relationship field pointing at another list.
@@ -71,10 +79,10 @@ function chainConfig(count: number): {
71
79
  const listName = `L${i}`
72
80
  const fields: Record<string, FieldConfig> = { name: { type: 'text' } as FieldConfig }
73
81
  if (i < count - 1) {
74
- fields.next = rel(`L${i + 1}.prev`)
82
+ fields.next = rel(`L${i + 1}.prev`, true)
75
83
  }
76
84
  if (i > 0) {
77
- fields.prev = rel(`L${i - 1}.next`)
85
+ fields.prev = rel(`L${i - 1}.next`, true)
78
86
  }
79
87
  const filter = { ownerId: { equals: listName } }
80
88
  const queryFn = vi.fn(i === 0 ? () => true : () => filter)
@@ -139,7 +147,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
139
147
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
140
148
  } as any as OpenSaasConfig
141
149
 
142
- const include = await buildAccessScopedInclude(
150
+ const { include } = await buildAccessScopedInclude(
143
151
  { b: true },
144
152
  config.lists.A.fields,
145
153
  { session: null, context: makeContext() },
@@ -155,7 +163,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
155
163
  it("fetches a named relation's own columns and stops — does not auto-expand its subtree (One hop)", async () => {
156
164
  const { config, queryFns } = chainConfig(4) // L0 → L1 → L2 → L3
157
165
 
158
- const include = await buildAccessScopedInclude(
166
+ const { include } = await buildAccessScopedInclude(
159
167
  { next: true },
160
168
  config.lists.L0.fields,
161
169
  { session: null, context: makeContext() },
@@ -175,7 +183,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
175
183
  it('scopes a nested path at every level the request names it', async () => {
176
184
  const { config, queryFns } = chainConfig(4) // L0 → L1 → L2 → L3
177
185
 
178
- const include = await buildAccessScopedInclude(
186
+ const { include } = await buildAccessScopedInclude(
179
187
  { next: { include: { next: { include: { next: true } } } } },
180
188
  config.lists.L0.fields,
181
189
  { session: null, context: makeContext() },
@@ -215,7 +223,7 @@ describe('buildAccessScopedInclude — caller-directed walk (ADR-0026)', () => {
215
223
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
216
224
  } as any as OpenSaasConfig
217
225
 
218
- const include = await buildAccessScopedInclude(
226
+ const { include } = await buildAccessScopedInclude(
219
227
  { b: { include: { c: { include: { a: true } } } } },
220
228
  config.lists.A.fields,
221
229
  { session: null, context: makeContext() },
@@ -253,7 +261,7 @@ describe('buildAccessScopedInclude — caller take on a to-many relation (issue
253
261
  it('preserves the take and AND-combines it with the access where', async () => {
254
262
  const config = scopedConfig()
255
263
 
256
- const include = await buildAccessScopedInclude(
264
+ const { include } = await buildAccessScopedInclude(
257
265
  { posts: { take: 10 } },
258
266
  config.lists.User.fields,
259
267
  { session: null, context: makeContext() },
@@ -276,7 +284,7 @@ describe('buildAccessScopedInclude — caller take on a to-many relation (issue
276
284
  const config = scopedConfig()
277
285
  config.lists.Post.access = { operation: { query: () => false } }
278
286
 
279
- const include = await buildAccessScopedInclude(
287
+ const { include } = await buildAccessScopedInclude(
280
288
  { posts: { take: 10 } },
281
289
  config.lists.User.fields,
282
290
  { session: null, context: makeContext() },
@@ -342,7 +350,7 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
342
350
  // Outer `next` (hop 1) + nestedInclude(HOPS_AT_CAP - 1) = HOPS_AT_CAP hops total — right at the boundary.
343
351
  const requested = { next: nestedInclude(HOPS_AT_CAP - 1) }
344
352
 
345
- const include = await buildAccessScopedInclude(
353
+ const { include } = await buildAccessScopedInclude(
346
354
  requested,
347
355
  config.lists.L0.fields,
348
356
  { session: null, context: makeContext() },
@@ -373,7 +381,7 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
373
381
  const chainLength = HOPS_AT_CAP + 3
374
382
  const { config } = chainConfig(chainLength)
375
383
 
376
- const include = await buildAccessScopedInclude(
384
+ const { include } = await buildAccessScopedInclude(
377
385
  {},
378
386
  config.lists.L0.fields,
379
387
  { session: null, context: makeContext() },
@@ -397,7 +405,7 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
397
405
 
398
406
  // An arbitrary (non-declared-relationship) key passed through unchanged —
399
407
  // access control does not govern keys it doesn't recognize as relationships.
400
- const include = await buildAccessScopedInclude(
408
+ const { include } = await buildAccessScopedInclude(
401
409
  { someUnrelatedKey: true },
402
410
  config.lists.Leaf.fields,
403
411
  { session: null, context: makeContext() },
@@ -407,3 +415,261 @@ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap
407
415
  expect(include).toEqual({ someUnrelatedKey: true })
408
416
  })
409
417
  })
418
+
419
+ /**
420
+ * Regression coverage for issue #974: Prisma accepts a nested `where` on an
421
+ * `include` entry only for a to-many relation — the same shape on a to-one
422
+ * relation raises `PrismaClientValidationError`. `buildAccessScopedInclude`
423
+ * must never attach one for a to-one relation, whatever its related list's
424
+ * `query` access resolves to; the scoping information goes into
425
+ * `toOneAccessFilters` instead.
426
+ */
427
+ describe('buildAccessScopedInclude — to-one relations record a post-query filter, not `where` (issue #974)', () => {
428
+ function ownerConfig(queryAccess: unknown): OpenSaasConfig {
429
+ return {
430
+ db: { provider: 'sqlite' },
431
+ lists: {
432
+ Child: {
433
+ fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
434
+ access: { operation: { query: () => true } },
435
+ },
436
+ Owner: {
437
+ fields: { name: { type: 'text' } as FieldConfig },
438
+ access: { operation: { query: queryAccess } },
439
+ },
440
+ },
441
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
442
+ } as any
443
+ }
444
+
445
+ it('records the access filter instead of attaching `where` when the related list scopes by a filter', async () => {
446
+ const config = ownerConfig(() => ({ id: { equals: 'u1' } }))
447
+
448
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
449
+ { owner: true },
450
+ config.lists.Child.fields,
451
+ { session: null, context: makeContext() },
452
+ config,
453
+ 'Child',
454
+ )
455
+
456
+ // The Prisma-bound include never carries a `where` on this to-one key —
457
+ // Prisma would reject it.
458
+ expect(include).toEqual({ owner: true })
459
+ expect(toOneAccessFilters).toEqual({
460
+ filters: {
461
+ owner: { kind: 'scoped', relatedListName: 'Owner', accessWhere: { id: { equals: 'u1' } } },
462
+ },
463
+ nested: {},
464
+ })
465
+ })
466
+
467
+ it('leaves a to-one relation whose related list is fully open untouched — no filter recorded, no extra query later', async () => {
468
+ const config = ownerConfig(() => true)
469
+
470
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
471
+ { owner: true },
472
+ config.lists.Child.fields,
473
+ { session: null, context: makeContext() },
474
+ config,
475
+ 'Child',
476
+ )
477
+
478
+ expect(include).toEqual({ owner: true })
479
+ expect(toOneAccessFilters).toEqual({ filters: {}, nested: {} })
480
+ })
481
+
482
+ it('drops a to-one relation whose related list denies query access outright, and records the denial', async () => {
483
+ const config = ownerConfig(() => false)
484
+
485
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
486
+ { owner: true },
487
+ config.lists.Child.fields,
488
+ { session: null, context: makeContext() },
489
+ config,
490
+ 'Child',
491
+ )
492
+
493
+ // Never asked of Prisma at all — same as before this fix.
494
+ expect(include).toEqual({})
495
+ expect(toOneAccessFilters).toEqual({ filters: { owner: { kind: 'denied' } }, nested: {} })
496
+ })
497
+
498
+ it('records a to-one filter nested inside another relation the caller explicitly included', async () => {
499
+ const config: OpenSaasConfig = {
500
+ db: { provider: 'sqlite' },
501
+ lists: {
502
+ GrandChild: {
503
+ fields: { name: { type: 'text' } as FieldConfig, child: rel('Child') },
504
+ access: { operation: { query: () => true } },
505
+ },
506
+ Child: {
507
+ fields: { name: { type: 'text' } as FieldConfig, owner: rel('Owner') },
508
+ access: { operation: { query: () => true } },
509
+ },
510
+ Owner: {
511
+ fields: { name: { type: 'text' } as FieldConfig },
512
+ access: { operation: { query: () => ({ id: { equals: 'u1' } }) } },
513
+ },
514
+ },
515
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
516
+ } as any
517
+
518
+ const { include, toOneAccessFilters } = await buildAccessScopedInclude(
519
+ { child: { include: { owner: true } } },
520
+ config.lists.GrandChild.fields,
521
+ { session: null, context: makeContext() },
522
+ config,
523
+ 'GrandChild',
524
+ )
525
+
526
+ expect(include).toEqual({ child: { include: { owner: true } } })
527
+ expect(toOneAccessFilters).toEqual({
528
+ filters: {},
529
+ nested: {
530
+ child: {
531
+ filters: {
532
+ owner: {
533
+ kind: 'scoped',
534
+ relatedListName: 'Owner',
535
+ accessWhere: { id: { equals: 'u1' } },
536
+ },
537
+ },
538
+ nested: {},
539
+ },
540
+ },
541
+ })
542
+ })
543
+ })
544
+
545
+ describe('resolveToOneAccessVisibility (issue #974)', () => {
546
+ function makeVisibilityContext(findMany: ReturnType<typeof vi.fn>): AccessContext {
547
+ return {
548
+ session: null,
549
+ _isSudo: false,
550
+ _resolveOutputChain: [],
551
+ prisma: { owner: { findMany } },
552
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
553
+ } as any
554
+ }
555
+
556
+ it('passes a denied filter straight through with no query', async () => {
557
+ const findMany = vi.fn()
558
+ const context = makeVisibilityContext(findMany)
559
+
560
+ const resolved = await resolveToOneAccessVisibility(
561
+ [{ id: 'c1' }],
562
+ { filters: { owner: { kind: 'denied' } }, nested: {} },
563
+ { session: null, context },
564
+ )
565
+
566
+ expect(resolved).toEqual({ filters: { owner: { kind: 'denied' } }, nested: {} })
567
+ expect(findMany).not.toHaveBeenCalled()
568
+ })
569
+
570
+ it('skips the query when no row carries a value at the scoped key', async () => {
571
+ const findMany = vi.fn()
572
+ const context = makeVisibilityContext(findMany)
573
+ const tree = {
574
+ filters: {
575
+ owner: {
576
+ kind: 'scoped' as const,
577
+ relatedListName: 'Owner',
578
+ accessWhere: { id: { equals: 'u1' } },
579
+ },
580
+ },
581
+ nested: {},
582
+ }
583
+
584
+ const resolved = await resolveToOneAccessVisibility([{ id: 'c1', owner: null }], tree, {
585
+ session: null,
586
+ context,
587
+ })
588
+
589
+ expect(resolved).toEqual({
590
+ filters: { owner: { kind: 'visible', ids: new Set() } },
591
+ nested: {},
592
+ })
593
+ expect(findMany).not.toHaveBeenCalled()
594
+ })
595
+
596
+ it('batches every row into ONE existence check, through the raw Prisma client, using the exact access filter', async () => {
597
+ const findMany = vi.fn().mockResolvedValue([{ id: 'u1' }, { id: 'u2' }])
598
+ const context = makeVisibilityContext(findMany)
599
+ const tree = {
600
+ filters: {
601
+ owner: {
602
+ kind: 'scoped' as const,
603
+ relatedListName: 'Owner',
604
+ accessWhere: { studioId: { equals: 's1' } },
605
+ },
606
+ },
607
+ nested: {},
608
+ }
609
+ const items = [
610
+ { id: 'c1', owner: { id: 'u1' } },
611
+ { id: 'c2', owner: { id: 'u2' } },
612
+ { id: 'c3', owner: { id: 'u3' } }, // excluded by the mocked response below
613
+ { id: 'c4', owner: null }, // contributes no id
614
+ ]
615
+
616
+ const resolved = await resolveToOneAccessVisibility(items, tree, { session: null, context })
617
+
618
+ // One call, not one per row.
619
+ expect(findMany).toHaveBeenCalledTimes(1)
620
+ expect(findMany).toHaveBeenCalledWith({
621
+ where: { AND: [{ studioId: { equals: 's1' } }, { id: { in: ['u1', 'u2', 'u3'] } }] },
622
+ select: { id: true },
623
+ })
624
+ expect(resolved.filters.owner).toEqual({ kind: 'visible', ids: new Set(['u1', 'u2']) })
625
+ })
626
+
627
+ it('recurses into nested trees, flattening rows reached through a to-many hop into one batch', async () => {
628
+ const ownerFindMany = vi.fn().mockResolvedValue([{ id: 'u1' }])
629
+ const context: AccessContext = {
630
+ session: null,
631
+ _isSudo: false,
632
+ _resolveOutputChain: [],
633
+ prisma: { owner: { findMany: ownerFindMany } },
634
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
635
+ } as any
636
+ const tree = {
637
+ filters: {},
638
+ nested: {
639
+ children: {
640
+ filters: {
641
+ owner: {
642
+ kind: 'scoped' as const,
643
+ relatedListName: 'Owner',
644
+ accessWhere: { id: { equals: 'u1' } },
645
+ },
646
+ },
647
+ nested: {},
648
+ },
649
+ },
650
+ }
651
+ const items = [
652
+ {
653
+ id: 'p1',
654
+ children: [
655
+ { id: 'c1', owner: { id: 'u1' } },
656
+ { id: 'c2', owner: { id: 'u9' } },
657
+ ],
658
+ },
659
+ { id: 'p2', children: [{ id: 'c3', owner: { id: 'u1' } }] },
660
+ ]
661
+
662
+ const resolved = await resolveToOneAccessVisibility(items, tree, { session: null, context })
663
+
664
+ // Every child across BOTH parents resolved in one batched call.
665
+ expect(ownerFindMany).toHaveBeenCalledTimes(1)
666
+ expect(ownerFindMany).toHaveBeenCalledWith({
667
+ where: { AND: [{ id: { equals: 'u1' } }, { id: { in: ['u1', 'u9'] } }] },
668
+ select: { id: true },
669
+ })
670
+ expect(resolved.nested.children.filters.owner).toEqual({
671
+ kind: 'visible',
672
+ ids: new Set(['u1']),
673
+ })
674
+ })
675
+ })