@opensaas/stack-core 0.30.0 → 0.31.1

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 (127) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +412 -0
  3. package/dist/access/access-filter.d.ts.map +1 -1
  4. package/dist/access/access-filter.js +14 -1
  5. package/dist/access/access-filter.js.map +1 -1
  6. package/dist/access/access-filter.test.js +50 -0
  7. package/dist/access/access-filter.test.js.map +1 -1
  8. package/dist/access/field-access.js +6 -6
  9. package/dist/access/field-access.js.map +1 -1
  10. package/dist/access/multi-column-read-write.test.js +46 -12
  11. package/dist/access/multi-column-read-write.test.js.map +1 -1
  12. package/dist/access/relationship-count.d.ts +60 -0
  13. package/dist/access/relationship-count.d.ts.map +1 -0
  14. package/dist/access/relationship-count.js +233 -0
  15. package/dist/access/relationship-count.js.map +1 -0
  16. package/dist/access/relationship-count.test.d.ts +2 -0
  17. package/dist/access/relationship-count.test.d.ts.map +1 -0
  18. package/dist/access/relationship-count.test.js +185 -0
  19. package/dist/access/relationship-count.test.js.map +1 -0
  20. package/dist/access/relationship-label-filter.d.ts +45 -0
  21. package/dist/access/relationship-label-filter.d.ts.map +1 -0
  22. package/dist/access/relationship-label-filter.js +93 -0
  23. package/dist/access/relationship-label-filter.js.map +1 -0
  24. package/dist/access/relationship-label-filter.test.d.ts +2 -0
  25. package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
  26. package/dist/access/relationship-label-filter.test.js +126 -0
  27. package/dist/access/relationship-label-filter.test.js.map +1 -0
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/index.d.ts.map +1 -1
  30. package/dist/config/nav-count.d.ts +34 -0
  31. package/dist/config/nav-count.d.ts.map +1 -0
  32. package/dist/config/nav-count.js +71 -0
  33. package/dist/config/nav-count.js.map +1 -0
  34. package/dist/config/types.d.ts +305 -4
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/context/hook-pipeline.d.ts.map +1 -1
  37. package/dist/context/hook-pipeline.js +11 -1
  38. package/dist/context/hook-pipeline.js.map +1 -1
  39. package/dist/context/index.d.ts +28 -0
  40. package/dist/context/index.d.ts.map +1 -1
  41. package/dist/context/index.js +205 -0
  42. package/dist/context/index.js.map +1 -1
  43. package/dist/context/nested-operations.d.ts.map +1 -1
  44. package/dist/context/nested-operations.js +7 -1
  45. package/dist/context/nested-operations.js.map +1 -1
  46. package/dist/extend.d.ts +1 -0
  47. package/dist/extend.d.ts.map +1 -1
  48. package/dist/fields/index.d.ts +1 -1
  49. package/dist/fields/index.d.ts.map +1 -1
  50. package/dist/fields/index.js +166 -0
  51. package/dist/fields/index.js.map +1 -1
  52. package/dist/filter/collect.d.ts +32 -0
  53. package/dist/filter/collect.d.ts.map +1 -0
  54. package/dist/filter/collect.js +60 -0
  55. package/dist/filter/collect.js.map +1 -0
  56. package/dist/filter/filter.test.d.ts +2 -0
  57. package/dist/filter/filter.test.d.ts.map +1 -0
  58. package/dist/filter/filter.test.js +348 -0
  59. package/dist/filter/filter.test.js.map +1 -0
  60. package/dist/filter/index.d.ts +7 -0
  61. package/dist/filter/index.d.ts.map +1 -0
  62. package/dist/filter/index.js +16 -0
  63. package/dist/filter/index.js.map +1 -0
  64. package/dist/filter/map.d.ts +18 -0
  65. package/dist/filter/map.d.ts.map +1 -0
  66. package/dist/filter/map.js +60 -0
  67. package/dist/filter/map.js.map +1 -0
  68. package/dist/filter/parse.d.ts +33 -0
  69. package/dist/filter/parse.d.ts.map +1 -0
  70. package/dist/filter/parse.js +103 -0
  71. package/dist/filter/parse.js.map +1 -0
  72. package/dist/filter/serialize.d.ts +25 -0
  73. package/dist/filter/serialize.d.ts.map +1 -0
  74. package/dist/filter/serialize.js +68 -0
  75. package/dist/filter/serialize.js.map +1 -0
  76. package/dist/filter/serialize.test.d.ts +2 -0
  77. package/dist/filter/serialize.test.d.ts.map +1 -0
  78. package/dist/filter/serialize.test.js +83 -0
  79. package/dist/filter/serialize.test.js.map +1 -0
  80. package/dist/filter/types.d.ts +122 -0
  81. package/dist/filter/types.d.ts.map +1 -0
  82. package/dist/filter/types.js +24 -0
  83. package/dist/filter/types.js.map +1 -0
  84. package/dist/hooks/index.d.ts +30 -0
  85. package/dist/hooks/index.d.ts.map +1 -1
  86. package/dist/hooks/index.js +79 -59
  87. package/dist/hooks/index.js.map +1 -1
  88. package/dist/index.d.ts +6 -1
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +19 -0
  91. package/dist/index.js.map +1 -1
  92. package/dist/internal.d.ts +1 -0
  93. package/dist/internal.d.ts.map +1 -1
  94. package/dist/internal.js +4 -0
  95. package/dist/internal.js.map +1 -1
  96. package/package.json +1 -1
  97. package/src/access/access-filter.test.ts +79 -0
  98. package/src/access/access-filter.ts +15 -4
  99. package/src/access/field-access.ts +6 -6
  100. package/src/access/multi-column-read-write.test.ts +68 -19
  101. package/src/access/relationship-count.test.ts +269 -0
  102. package/src/access/relationship-count.ts +311 -0
  103. package/src/access/relationship-label-filter.test.ts +177 -0
  104. package/src/access/relationship-label-filter.ts +140 -0
  105. package/src/config/index.ts +6 -0
  106. package/src/config/nav-count.ts +85 -0
  107. package/src/config/types.ts +319 -1
  108. package/src/context/hook-pipeline.ts +19 -0
  109. package/src/context/index.ts +285 -3
  110. package/src/context/nested-operations.ts +22 -0
  111. package/src/extend.ts +9 -0
  112. package/src/fields/index.ts +168 -0
  113. package/src/filter/collect.ts +72 -0
  114. package/src/filter/filter.test.ts +400 -0
  115. package/src/filter/index.ts +25 -0
  116. package/src/filter/map.ts +68 -0
  117. package/src/filter/parse.ts +106 -0
  118. package/src/filter/serialize.test.ts +115 -0
  119. package/src/filter/serialize.ts +75 -0
  120. package/src/filter/types.ts +120 -0
  121. package/src/hooks/index.ts +87 -57
  122. package/src/index.ts +54 -0
  123. package/src/internal.ts +5 -0
  124. package/tests/context.test.ts +690 -0
  125. package/tests/multi-column-validation-ordering.test.ts +305 -0
  126. package/tests/nav-count.test.ts +189 -0
  127. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { list } from '../config/index.js'
3
+ import { text, relationship, integer } from '../fields/index.js'
4
+ import type { OpenSaasConfig } from '../config/types.js'
5
+ import type { AccessContext } from './types.js'
6
+ import {
7
+ resolveRelationshipLabelFilters,
8
+ isToOneRelationshipField,
9
+ } from './relationship-label-filter.js'
10
+
11
+ /**
12
+ * Access-scoped to-one relationship label filters (issue #749). Verifies that
13
+ * `author:Ada` → `{ author: { is: { name: { contains: 'Ada' } } } }` gets the
14
+ * related list's `query` access folded into the nested `is` clause, so a
15
+ * session can never distinguish parent rows by a related field it cannot
16
+ * itself read.
17
+ */
18
+
19
+ // Post is fully open; User (the `author` relation target) is scoped so only
20
+ // active users are queryable; Widget ships fully closed (no access block).
21
+ function makeConfig(): OpenSaasConfig {
22
+ return {
23
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
24
+ lists: {
25
+ User: list({
26
+ fields: { name: text(), active: integer() },
27
+ access: { operation: { query: () => ({ active: { equals: 1 } }) } },
28
+ }),
29
+ Post: list({
30
+ fields: {
31
+ title: text(),
32
+ views: integer(),
33
+ author: relationship({ ref: 'User.posts' }),
34
+ widget: relationship({ ref: 'Widget' }),
35
+ },
36
+ access: { operation: { query: () => true } },
37
+ }),
38
+ // Widget ships closed (no access block) → query denied by default.
39
+ Widget: list({ fields: { name: text() } }),
40
+ },
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
42
+ } as any
43
+ }
44
+
45
+ function makeContext(): AccessContext {
46
+ return {
47
+ session: null,
48
+ _isSudo: false,
49
+ _resolveOutputCounter: { depth: 0 },
50
+ db: {},
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
52
+ } as any
53
+ }
54
+
55
+ describe('isToOneRelationshipField', () => {
56
+ const config = makeConfig()
57
+ const postFields = config.lists.Post.fields
58
+
59
+ it('is true only for a to-one relationship', () => {
60
+ expect(isToOneRelationshipField(postFields.author)).toBe(true)
61
+ expect(isToOneRelationshipField(postFields.widget)).toBe(true)
62
+ expect(isToOneRelationshipField(postFields.title)).toBe(false)
63
+ })
64
+ })
65
+
66
+ describe('resolveRelationshipLabelFilters', () => {
67
+ it('returns the where unchanged when there are no label-filter members', async () => {
68
+ const config = makeConfig()
69
+ const where = { title: { contains: 'hello' } }
70
+ const resolved = await resolveRelationshipLabelFilters(
71
+ where,
72
+ config.lists.Post,
73
+ { session: null, context: makeContext() },
74
+ config,
75
+ )
76
+ expect(resolved).toBe(where)
77
+ })
78
+
79
+ it("ANDs the related list's access filter into the nested `is` clause", async () => {
80
+ const config = makeConfig()
81
+ const where = { author: { is: { name: { contains: 'Ada' } } } }
82
+ const resolved = await resolveRelationshipLabelFilters(
83
+ where,
84
+ config.lists.Post,
85
+ { session: null, context: makeContext() },
86
+ config,
87
+ )
88
+ expect(resolved).toEqual({
89
+ author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } },
90
+ })
91
+ })
92
+
93
+ it('leaves the member unchanged when the related list is fully readable', async () => {
94
+ const config: OpenSaasConfig = {
95
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
96
+ lists: {
97
+ Tag: list({ fields: { name: text() }, access: { operation: { query: () => true } } }),
98
+ Post: list({ fields: { title: text(), tag: relationship({ ref: 'Tag' }) } }),
99
+ },
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
101
+ } as any
102
+ const where = { tag: { is: { name: { contains: 'news' } } } }
103
+ const resolved = await resolveRelationshipLabelFilters(
104
+ where,
105
+ config.lists.Post,
106
+ { session: null, context: makeContext() },
107
+ config,
108
+ )
109
+ expect(resolved).toEqual(where)
110
+ })
111
+
112
+ it('resolves a fully denied related list to a never-matching member (no leak)', async () => {
113
+ const config = makeConfig()
114
+ const where = { widget: { is: { name: { contains: 'Gadget' } } } }
115
+ const resolved = await resolveRelationshipLabelFilters(
116
+ where,
117
+ config.lists.Post,
118
+ { session: null, context: makeContext() },
119
+ config,
120
+ )
121
+ expect(resolved).toEqual({ id: { in: [] } })
122
+ })
123
+
124
+ it('preserves sibling conditions, ANDing the resolved id constraint for a denied member', async () => {
125
+ const config = makeConfig()
126
+ const where = {
127
+ AND: [{ title: { contains: 'hello' } }, { widget: { is: { name: { contains: 'Gadget' } } } }],
128
+ }
129
+ const resolved = await resolveRelationshipLabelFilters(
130
+ where,
131
+ config.lists.Post,
132
+ { session: null, context: makeContext() },
133
+ config,
134
+ )
135
+ expect(resolved).toEqual({
136
+ AND: [{ title: { contains: 'hello' } }, { id: { in: [] } }],
137
+ })
138
+ })
139
+
140
+ it('preserves sibling conditions alongside the access-scoped nested `is` clause', async () => {
141
+ const config = makeConfig()
142
+ const where = {
143
+ AND: [{ title: { contains: 'hello' } }, { author: { is: { name: { contains: 'Ada' } } } }],
144
+ }
145
+ const resolved = await resolveRelationshipLabelFilters(
146
+ where,
147
+ config.lists.Post,
148
+ { session: null, context: makeContext() },
149
+ config,
150
+ )
151
+ expect(resolved).toEqual({
152
+ AND: [
153
+ { title: { contains: 'hello' } },
154
+ { author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
155
+ ],
156
+ })
157
+ })
158
+
159
+ it('composes with a to-many count marker member left untouched by this resolver', async () => {
160
+ const config = makeConfig()
161
+ const where = {
162
+ AND: [{ author: { is: { name: { contains: 'Ada' } } } }, { views: { gt: 10 } }],
163
+ }
164
+ const resolved = await resolveRelationshipLabelFilters(
165
+ where,
166
+ config.lists.Post,
167
+ { session: null, context: makeContext() },
168
+ config,
169
+ )
170
+ expect(resolved).toEqual({
171
+ AND: [
172
+ { author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
173
+ { views: { gt: 10 } },
174
+ ],
175
+ })
176
+ })
177
+ })
@@ -0,0 +1,140 @@
1
+ import type { Session, AccessContext } from './types.js'
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
+
6
+ /**
7
+ * Access-scoped to-one relationship label filters for the admin list view
8
+ * (issue #749).
9
+ *
10
+ * A to-one relationship's Filter spec (`author:Ada` → `{ author: { is: { name:
11
+ * { contains: 'Ada' } } } }`) is a pure mapper (see `relationship()` in
12
+ * `fields/index.ts`) — it has no way to consult the related list's access
13
+ * control, so the nested `is` clause it emits runs as an unscoped Prisma
14
+ * sub-filter against the related table. The parent list's own access filter
15
+ * still scopes which parent rows are visible, but the nested condition itself
16
+ * is evaluated with no reference to the related list's `query` access — a
17
+ * session could distinguish parent rows by a related field it is not itself
18
+ * allowed to read (e.g. binary-searching `author:A`, `author:Ad`, `author:Ada`
19
+ * against a `User` list it cannot query).
20
+ *
21
+ * This module is the single place the related list's operation-level `query`
22
+ * access is folded into that nested `is` clause, mirroring how
23
+ * `relationship-count.ts` folds it into `_count` selects and count-filter
24
+ * markers. A fully denied related list makes the member never match — the
25
+ * token cannot be used to confirm or rule out a value on a field the session
26
+ * cannot read (narrowing-only, matching the count resolver's denied path).
27
+ */
28
+
29
+ type LabelFilterArgs = {
30
+ session: Session | null
31
+ context: AccessContext
32
+ }
33
+
34
+ /**
35
+ * Whether a field is a to-one relationship — the only field kind whose Filter
36
+ * spec emits a nested `{ is: {...} } }` condition against the related list's
37
+ * label field.
38
+ */
39
+ export function isToOneRelationshipField(field: FieldConfig | undefined): boolean {
40
+ return (
41
+ field?.type === 'relationship' &&
42
+ !('many' in field && field.many === true) &&
43
+ 'ref' in field &&
44
+ typeof field.ref === 'string' &&
45
+ field.ref.length > 0
46
+ )
47
+ }
48
+
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
+ /**
86
+ * Replace any to-one relationship label-filter members in a filter `where`
87
+ * with access-scoped equivalents. Label-filter members only ever appear as
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.
92
+ */
93
+ export async function resolveRelationshipLabelFilters(
94
+ where: Record<string, unknown> | undefined,
95
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
96
+ listConfig: ListConfig<any>,
97
+ args: LabelFilterArgs,
98
+ config: OpenSaasConfig,
99
+ ): Promise<Record<string, unknown> | undefined> {
100
+ if (!where) return where
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 }
140
+ }
@@ -150,6 +150,12 @@ export type {
150
150
  UIConfig,
151
151
  ListUIConfig,
152
152
  ListViewUIConfig,
153
+ BulkAction,
154
+ BulkActionContext,
155
+ BulkActionResult,
156
+ BulkActionVariant,
157
+ ItemViewUIConfig,
158
+ RelationshipItemViewConfig,
153
159
  ThemeConfig,
154
160
  ThemePreset,
155
161
  ThemeColors,
@@ -0,0 +1,85 @@
1
+ import type { AccessContext } from '../access/types.js'
2
+ import { getDbKey } from '../lib/case-utils.js'
3
+ import type { ListConfig, OpenSaasConfig, TypeInfo } from './types.js'
4
+
5
+ /**
6
+ * The minimal shape a `context.db[list]` delegate needs for a nav count. The
7
+ * access-controlled DB carries an `any` index signature, so this narrows the
8
+ * lookup to just the access-scoped `count` we call.
9
+ */
10
+ type CountDelegate = {
11
+ count?: (args?: { where?: unknown }) => Promise<number>
12
+ }
13
+
14
+ /**
15
+ * Whether a list's query access is **statically denied** — provably no session
16
+ * can read any row without evaluating a session-dependent function.
17
+ *
18
+ * Mirrors the list view's `canDeleteList` static check (issue #733): query
19
+ * access is statically denied when it is absent (deny-by-default, matching the
20
+ * access engine) or the literal boolean `false`. A function — or `true` —
21
+ * cannot be decided up front, so the count is fetched through the secured
22
+ * context, which re-applies the real rule per row.
23
+ *
24
+ * A statically-denied list must render no nav count at all: a `0` there would
25
+ * imply "this list is empty" when the truth is "you may see none of it".
26
+ */
27
+ export function isListQueryStaticallyDenied(
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
29
+ listConfig: ListConfig<any>,
30
+ ): boolean {
31
+ const queryAccess = listConfig.access?.operation?.query
32
+ if (queryAccess === undefined) return true
33
+ if (typeof queryAccess === 'boolean') return !queryAccess
34
+ return false
35
+ }
36
+
37
+ /**
38
+ * Resolve the access-scoped nav counts for the lists that opt in via
39
+ * `ui.navCount` (issue #735).
40
+ *
41
+ * The returned map is keyed by list key and contains an entry **only** for a
42
+ * list that:
43
+ *
44
+ * 1. opts in (`ui.navCount === true`) — no count query runs for any other list,
45
+ * 2. is not a singleton (a single-record list has no meaningful count), and
46
+ * 3. does not have statically-denied query access (see
47
+ * {@link isListQueryStaticallyDenied}).
48
+ *
49
+ * Each count is read through the secured `context.db`, whose `count` applies the
50
+ * access filter, so the number reflects exactly what the current session may
51
+ * see (a denied row is not counted). Counts are fetched concurrently.
52
+ */
53
+ export async function resolveNavCounts(
54
+ context: AccessContext,
55
+ config: OpenSaasConfig,
56
+ ): Promise<Record<string, number>> {
57
+ const counts: Record<string, number> = {}
58
+ const lists = config.lists ?? {}
59
+
60
+ const optedIn = Object.keys(lists).filter((listKey) => {
61
+ const listConfig = lists[listKey] as ListConfig<TypeInfo> | undefined
62
+ if (!listConfig) return false
63
+ if (listConfig.ui?.navCount !== true) return false
64
+ if (listConfig.isSingleton) return false
65
+ return !isListQueryStaticallyDenied(listConfig)
66
+ })
67
+
68
+ await Promise.all(
69
+ optedIn.map(async (listKey) => {
70
+ const delegate: CountDelegate | undefined = context.db?.[getDbKey(listKey)]
71
+ if (!delegate?.count) return
72
+ // A single list's count failing (a DB hiccup, a throwing access filter or
73
+ // hook) must not blank the whole admin chrome — `AdminUI` awaits this
74
+ // before rendering the shell. Degrade like the sibling `ListView`: log and
75
+ // omit just that badge. Other lists' counts are unaffected.
76
+ try {
77
+ counts[listKey] = await delegate.count()
78
+ } catch (error) {
79
+ console.error(`Failed to resolve nav count for ${listKey}:`, error)
80
+ }
81
+ }),
82
+ )
83
+
84
+ return counts
85
+ }