@opensaas/stack-core 0.29.0 → 0.31.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 (133) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +443 -0
  3. package/CLAUDE.md +4 -4
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +14 -1
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.js +50 -0
  8. package/dist/access/access-filter.test.js.map +1 -1
  9. package/dist/access/field-access.js +6 -6
  10. package/dist/access/field-access.js.map +1 -1
  11. package/dist/access/multi-column-read-write.test.js +46 -12
  12. package/dist/access/multi-column-read-write.test.js.map +1 -1
  13. package/dist/access/relationship-count.d.ts +60 -0
  14. package/dist/access/relationship-count.d.ts.map +1 -0
  15. package/dist/access/relationship-count.js +233 -0
  16. package/dist/access/relationship-count.js.map +1 -0
  17. package/dist/access/relationship-count.test.d.ts +2 -0
  18. package/dist/access/relationship-count.test.d.ts.map +1 -0
  19. package/dist/access/relationship-count.test.js +185 -0
  20. package/dist/access/relationship-count.test.js.map +1 -0
  21. package/dist/access/relationship-label-filter.d.ts +45 -0
  22. package/dist/access/relationship-label-filter.d.ts.map +1 -0
  23. package/dist/access/relationship-label-filter.js +93 -0
  24. package/dist/access/relationship-label-filter.js.map +1 -0
  25. package/dist/access/relationship-label-filter.test.d.ts +2 -0
  26. package/dist/access/relationship-label-filter.test.d.ts.map +1 -0
  27. package/dist/access/relationship-label-filter.test.js +126 -0
  28. package/dist/access/relationship-label-filter.test.js.map +1 -0
  29. package/dist/config/index.d.ts +1 -1
  30. package/dist/config/index.d.ts.map +1 -1
  31. package/dist/config/nav-count.d.ts +34 -0
  32. package/dist/config/nav-count.d.ts.map +1 -0
  33. package/dist/config/nav-count.js +71 -0
  34. package/dist/config/nav-count.js.map +1 -0
  35. package/dist/config/types.d.ts +305 -4
  36. package/dist/config/types.d.ts.map +1 -1
  37. package/dist/context/hook-pipeline.d.ts.map +1 -1
  38. package/dist/context/hook-pipeline.js +11 -1
  39. package/dist/context/hook-pipeline.js.map +1 -1
  40. package/dist/context/index.d.ts +28 -0
  41. package/dist/context/index.d.ts.map +1 -1
  42. package/dist/context/index.js +206 -1
  43. package/dist/context/index.js.map +1 -1
  44. package/dist/context/nested-operations.d.ts.map +1 -1
  45. package/dist/context/nested-operations.js +7 -1
  46. package/dist/context/nested-operations.js.map +1 -1
  47. package/dist/extend.d.ts +1 -0
  48. package/dist/extend.d.ts.map +1 -1
  49. package/dist/fields/index.d.ts +1 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +166 -0
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/filter/collect.d.ts +32 -0
  54. package/dist/filter/collect.d.ts.map +1 -0
  55. package/dist/filter/collect.js +60 -0
  56. package/dist/filter/collect.js.map +1 -0
  57. package/dist/filter/filter.test.d.ts +2 -0
  58. package/dist/filter/filter.test.d.ts.map +1 -0
  59. package/dist/filter/filter.test.js +348 -0
  60. package/dist/filter/filter.test.js.map +1 -0
  61. package/dist/filter/index.d.ts +7 -0
  62. package/dist/filter/index.d.ts.map +1 -0
  63. package/dist/filter/index.js +16 -0
  64. package/dist/filter/index.js.map +1 -0
  65. package/dist/filter/map.d.ts +18 -0
  66. package/dist/filter/map.d.ts.map +1 -0
  67. package/dist/filter/map.js +60 -0
  68. package/dist/filter/map.js.map +1 -0
  69. package/dist/filter/parse.d.ts +33 -0
  70. package/dist/filter/parse.d.ts.map +1 -0
  71. package/dist/filter/parse.js +103 -0
  72. package/dist/filter/parse.js.map +1 -0
  73. package/dist/filter/serialize.d.ts +25 -0
  74. package/dist/filter/serialize.d.ts.map +1 -0
  75. package/dist/filter/serialize.js +68 -0
  76. package/dist/filter/serialize.js.map +1 -0
  77. package/dist/filter/serialize.test.d.ts +2 -0
  78. package/dist/filter/serialize.test.d.ts.map +1 -0
  79. package/dist/filter/serialize.test.js +83 -0
  80. package/dist/filter/serialize.test.js.map +1 -0
  81. package/dist/filter/types.d.ts +122 -0
  82. package/dist/filter/types.d.ts.map +1 -0
  83. package/dist/filter/types.js +24 -0
  84. package/dist/filter/types.js.map +1 -0
  85. package/dist/hooks/index.d.ts +30 -0
  86. package/dist/hooks/index.d.ts.map +1 -1
  87. package/dist/hooks/index.js +79 -59
  88. package/dist/hooks/index.js.map +1 -1
  89. package/dist/index.d.ts +6 -1
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +19 -0
  92. package/dist/index.js.map +1 -1
  93. package/dist/internal.d.ts +1 -0
  94. package/dist/internal.d.ts.map +1 -1
  95. package/dist/internal.js +4 -0
  96. package/dist/internal.js.map +1 -1
  97. package/dist/mcp/handler.d.ts +11 -3
  98. package/dist/mcp/handler.d.ts.map +1 -1
  99. package/dist/mcp/handler.js +96 -20
  100. package/dist/mcp/handler.js.map +1 -1
  101. package/package.json +1 -1
  102. package/src/access/access-filter.test.ts +79 -0
  103. package/src/access/access-filter.ts +15 -4
  104. package/src/access/field-access.ts +6 -6
  105. package/src/access/multi-column-read-write.test.ts +68 -19
  106. package/src/access/relationship-count.test.ts +269 -0
  107. package/src/access/relationship-count.ts +311 -0
  108. package/src/access/relationship-label-filter.test.ts +177 -0
  109. package/src/access/relationship-label-filter.ts +140 -0
  110. package/src/config/index.ts +6 -0
  111. package/src/config/nav-count.ts +85 -0
  112. package/src/config/types.ts +319 -1
  113. package/src/context/hook-pipeline.ts +19 -0
  114. package/src/context/index.ts +286 -4
  115. package/src/context/nested-operations.ts +22 -0
  116. package/src/extend.ts +9 -0
  117. package/src/fields/index.ts +168 -0
  118. package/src/filter/collect.ts +72 -0
  119. package/src/filter/filter.test.ts +400 -0
  120. package/src/filter/index.ts +25 -0
  121. package/src/filter/map.ts +68 -0
  122. package/src/filter/parse.ts +106 -0
  123. package/src/filter/serialize.test.ts +115 -0
  124. package/src/filter/serialize.ts +75 -0
  125. package/src/filter/types.ts +120 -0
  126. package/src/hooks/index.ts +87 -57
  127. package/src/index.ts +54 -0
  128. package/src/internal.ts +5 -0
  129. package/src/mcp/handler.ts +118 -28
  130. package/tests/context.test.ts +690 -0
  131. package/tests/multi-column-validation-ordering.test.ts +305 -0
  132. package/tests/nav-count.test.ts +189 -0
  133. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,305 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { z } from 'zod'
3
+ import { getContext } from '../src/context/index.js'
4
+ import { config, list } from '../src/config/index.js'
5
+ import { text, relationship } from '../src/fields/index.js'
6
+ import { ValidationError } from '../src/hooks/index.js'
7
+ import type { FieldConfig } from '../src/config/types.js'
8
+ import type { FieldAccess } from '../src/access/types.js'
9
+
10
+ /**
11
+ * #789: a multi-column field's `resolveInput` can have a documented
12
+ * "unrecognised value — return as-is and let validation catch it" fallback
13
+ * (see storage image()/file() in Keystone-parity mode). Before this fix, the
14
+ * split into physical columns ran BEFORE validation (inline inside
15
+ * `executeFieldResolveInputHooks`), so an unrecognised value was silently split
16
+ * into null/undefined columns and the write succeeded with corrupted/absent
17
+ * data instead of throwing.
18
+ *
19
+ * These are full `context.db` integration tests (top-level AND nested writes)
20
+ * proving validation now runs against the LOGICAL value first — an
21
+ * unrecognised value throws `ValidationError` and the DB is never touched —
22
+ * while the legitimate shapes (null/undefined and a valid object) continue to
23
+ * split and persist exactly as before.
24
+ */
25
+
26
+ /** A minimal multi-column field: two physical columns split from/assembled into `{ url, size }`. */
27
+ function mediaField(access?: FieldAccess): FieldConfig {
28
+ const COLUMNS = ['m_url', 'm_size']
29
+ return {
30
+ type: 'multiColumn',
31
+ access,
32
+ getColumnNames: () => COLUMNS,
33
+ assembleColumns: (_fieldName: string, row: Record<string, unknown>) => {
34
+ const url = row.m_url
35
+ if (url === null || url === undefined || url === '') return null
36
+ return { url, size: row.m_size ?? 0 }
37
+ },
38
+ splitColumns: (_fieldName: string, value: unknown) => {
39
+ if (value === null || value === undefined) {
40
+ return { m_url: null, m_size: null }
41
+ }
42
+ const v = value as { url?: unknown; size?: unknown }
43
+ return { m_url: v.url ?? null, m_size: v.size ?? null }
44
+ },
45
+ getZodSchema: () => z.object({ url: z.string(), size: z.number() }).nullable(),
46
+ hooks: {
47
+ // Mirrors storage image()/file()'s resolveInput contract: null/undefined
48
+ // and an already-shaped value pass through unchanged; anything else is
49
+ // the documented "unrecognised — return as-is and let validation catch
50
+ // it" fallback. This is the exact fallback #789 is about.
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic test hook
52
+ resolveInput: async ({ resolvedData, fieldKey }: any) => resolvedData?.[fieldKey],
53
+ },
54
+ } as unknown as FieldConfig
55
+ }
56
+
57
+ /**
58
+ * A tiny in-memory Prisma mock supporting a single nested to-one relation
59
+ * (`author` on `post`), for both nested create and nested update, mirroring
60
+ * the harness used by the other write-pipeline test suites.
61
+ */
62
+ function createTxPrisma() {
63
+ const tables: Record<string, Map<string, Record<string, unknown>>> = {
64
+ post: new Map(),
65
+ author: new Map(),
66
+ }
67
+ let idCounter = 0
68
+ const nextId = () => `id-${++idCounter}`
69
+
70
+ function applyNested(
71
+ table: string,
72
+ record: Record<string, unknown>,
73
+ data: Record<string, unknown>,
74
+ ): Record<string, unknown> {
75
+ const result = { ...record }
76
+ for (const [key, value] of Object.entries(data)) {
77
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
78
+ const nested = value as Record<string, unknown>
79
+ if (nested.create || nested.update) {
80
+ if (nested.create) {
81
+ const created = doCreate('author', nested.create as Record<string, unknown>)
82
+ result[`${key}Link`] = created.id
83
+ result[key] = created
84
+ }
85
+ if (nested.update) {
86
+ const upd = nested.update as { where: { id: string }; data: Record<string, unknown> }
87
+ const updated = doUpdate('author', upd.where, upd.data)
88
+ result[key] = updated
89
+ }
90
+ continue
91
+ }
92
+ }
93
+ result[key] = value
94
+ }
95
+ return result
96
+ }
97
+
98
+ function doCreate(table: string, data: Record<string, unknown>): Record<string, unknown> {
99
+ const id = (data.id as string) ?? nextId()
100
+ const record = applyNested(table, { id }, data)
101
+ tables[table].set(id, record)
102
+ return record
103
+ }
104
+
105
+ function doUpdate(
106
+ table: string,
107
+ where: { id: string },
108
+ data: Record<string, unknown>,
109
+ ): Record<string, unknown> {
110
+ const existing = tables[table].get(where.id) ?? { id: where.id }
111
+ const updated = applyNested(table, existing, data)
112
+ tables[table].set(where.id, updated)
113
+ return updated
114
+ }
115
+
116
+ function makeModel(table: string) {
117
+ return {
118
+ findUnique: vi.fn(
119
+ async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null,
120
+ ),
121
+ findFirst: vi.fn(async () => tables[table].values().next().value ?? null),
122
+ findMany: vi.fn(async () => Array.from(tables[table].values())),
123
+ count: vi.fn(async () => tables[table].size),
124
+ create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => doCreate(table, data)),
125
+ update: vi.fn(
126
+ async ({ where, data }: { where: { id: string }; data: Record<string, unknown> }) =>
127
+ doUpdate(table, where, data),
128
+ ),
129
+ delete: vi.fn(),
130
+ }
131
+ }
132
+
133
+ const client: Record<string, unknown> = {
134
+ post: makeModel('post'),
135
+ author: makeModel('author'),
136
+ }
137
+ client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => fn(client)
138
+
139
+ return { client, tables }
140
+ }
141
+
142
+ describe('#789 top-level write — multi-column validation runs BEFORE the split', () => {
143
+ let mock: ReturnType<typeof createTxPrisma>
144
+
145
+ beforeEach(() => {
146
+ mock = createTxPrisma()
147
+ vi.clearAllMocks()
148
+ })
149
+
150
+ function makeTestConfig() {
151
+ return config({
152
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
153
+ lists: {
154
+ Post: list({
155
+ fields: { title: text(), media: mediaField() },
156
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
157
+ }),
158
+ },
159
+ })
160
+ }
161
+
162
+ it('create: an unrecognised media value throws ValidationError and never reaches the DB', async () => {
163
+ const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
164
+
165
+ await expect(
166
+ context.db.post.create({ data: { title: 'hi', media: 'not-a-valid-shape' } }),
167
+ ).rejects.toBeInstanceOf(ValidationError)
168
+
169
+ expect(mock.tables.post.size).toBe(0)
170
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
171
+ expect((mock.client.post as any).create).not.toHaveBeenCalled()
172
+ })
173
+
174
+ it('create: a valid media value passes validation and is split into physical columns', async () => {
175
+ const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
176
+
177
+ const created = await context.db.post.create({
178
+ data: { title: 'hi', media: { url: 'https://x/y.jpg', size: 99 } },
179
+ })
180
+
181
+ expect(created).toBeTruthy()
182
+ const stored = mock.tables.post.values().next().value
183
+ expect(stored).toMatchObject({ m_url: 'https://x/y.jpg', m_size: 99 })
184
+ expect('media' in (stored ?? {})).toBe(false)
185
+ })
186
+
187
+ it('create: a null media value passes validation and clears the columns', async () => {
188
+ const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
189
+
190
+ await context.db.post.create({ data: { title: 'hi', media: null } })
191
+
192
+ const stored = mock.tables.post.values().next().value
193
+ expect(stored).toMatchObject({ m_url: null, m_size: null })
194
+ })
195
+
196
+ it('update: an unrecognised media value throws ValidationError and never reaches the DB', async () => {
197
+ mock.tables.post.set('p1', { id: 'p1', title: 'old', m_url: null, m_size: null })
198
+ const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
199
+
200
+ await expect(
201
+ context.db.post.update({ where: { id: 'p1' }, data: { media: 42 } }),
202
+ ).rejects.toBeInstanceOf(ValidationError)
203
+
204
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
205
+ expect((mock.client.post as any).update).not.toHaveBeenCalled()
206
+ expect(mock.tables.post.get('p1')).toMatchObject({ title: 'old', m_url: null, m_size: null })
207
+ })
208
+
209
+ it('update: a valid media value passes validation and is split into physical columns', async () => {
210
+ mock.tables.post.set('p1', { id: 'p1', title: 'old', m_url: null, m_size: null })
211
+ const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
212
+
213
+ await context.db.post.update({
214
+ where: { id: 'p1' },
215
+ data: { media: { url: 'https://x/new.jpg', size: 5 } },
216
+ })
217
+
218
+ expect(mock.tables.post.get('p1')).toMatchObject({ m_url: 'https://x/new.jpg', m_size: 5 })
219
+ })
220
+ })
221
+
222
+ describe('#789 nested write — multi-column validation runs BEFORE the split', () => {
223
+ let mock: ReturnType<typeof createTxPrisma>
224
+
225
+ beforeEach(() => {
226
+ mock = createTxPrisma()
227
+ vi.clearAllMocks()
228
+ })
229
+
230
+ function makeTestConfig() {
231
+ return config({
232
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
233
+ lists: {
234
+ Author: list({
235
+ fields: { name: text(), media: mediaField() },
236
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
237
+ }),
238
+ Post: list({
239
+ fields: { title: text(), author: relationship({ ref: 'Author' }) },
240
+ access: { operation: { query: () => true, create: () => true, update: () => true } },
241
+ }),
242
+ },
243
+ })
244
+ }
245
+
246
+ it('nested create: an unrecognised media value throws ValidationError and nothing persists', async () => {
247
+ const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
248
+
249
+ await expect(
250
+ context.db.post.create({
251
+ data: { title: 'p', author: { create: { name: 'a', media: 'not-a-valid-shape' } } },
252
+ }),
253
+ ).rejects.toBeInstanceOf(ValidationError)
254
+
255
+ expect(mock.tables.post.size).toBe(0)
256
+ expect(mock.tables.author.size).toBe(0)
257
+ })
258
+
259
+ it('nested create: a valid media value is split into physical columns on the nested row', async () => {
260
+ const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
261
+
262
+ await context.db.post.create({
263
+ data: {
264
+ title: 'p',
265
+ author: { create: { name: 'a', media: { url: 'https://x/y.jpg', size: 3 } } },
266
+ },
267
+ })
268
+
269
+ const author = mock.tables.author.values().next().value
270
+ expect(author).toMatchObject({ m_url: 'https://x/y.jpg', m_size: 3 })
271
+ expect('media' in (author ?? {})).toBe(false)
272
+ })
273
+
274
+ it('nested update: an unrecognised media value throws ValidationError and nothing persists', async () => {
275
+ mock.tables.post.set('p1', { id: 'p1', title: 'p', authorLink: 'a1' })
276
+ mock.tables.author.set('a1', { id: 'a1', name: 'old', m_url: null, m_size: null })
277
+ const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
278
+
279
+ await expect(
280
+ context.db.post.update({
281
+ where: { id: 'p1' },
282
+ data: { author: { update: { where: { id: 'a1' }, data: { media: [] } } } },
283
+ }),
284
+ ).rejects.toBeInstanceOf(ValidationError)
285
+
286
+ expect(mock.tables.author.get('a1')).toMatchObject({ name: 'old', m_url: null, m_size: null })
287
+ })
288
+
289
+ it('nested update: a valid media value is split into physical columns on the nested row', async () => {
290
+ mock.tables.post.set('p1', { id: 'p1', title: 'p', authorLink: 'a1' })
291
+ mock.tables.author.set('a1', { id: 'a1', name: 'old', m_url: null, m_size: null })
292
+ const context = getContext(await makeTestConfig(), mock.client, { userId: '1' })
293
+
294
+ await context.db.post.update({
295
+ where: { id: 'p1' },
296
+ data: {
297
+ author: {
298
+ update: { where: { id: 'a1' }, data: { media: { url: 'https://x/new.jpg', size: 8 } } },
299
+ },
300
+ },
301
+ })
302
+
303
+ expect(mock.tables.author.get('a1')).toMatchObject({ m_url: 'https://x/new.jpg', m_size: 8 })
304
+ })
305
+ })
@@ -0,0 +1,189 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { isListQueryStaticallyDenied, resolveNavCounts } from '../src/config/nav-count.js'
3
+ import type { AccessContext } from '../src/access/types.js'
4
+ import type { ListConfig, OpenSaasConfig, TypeInfo } from '../src/config/types.js'
5
+
6
+ /**
7
+ * Build a list config with a chosen query access and `ui.navCount` / singleton
8
+ * flags. Access is written in the normalized object form `list()` produces.
9
+ */
10
+ function makeList(options: {
11
+ queryAccess?: boolean | (() => boolean) | undefined
12
+ navCount?: boolean
13
+ isSingleton?: boolean
14
+ }): ListConfig<TypeInfo> {
15
+ const { queryAccess, navCount, isSingleton } = options
16
+ return {
17
+ fields: { title: { type: 'text' } as ListConfig<TypeInfo>['fields'][string] },
18
+ access: queryAccess === undefined ? undefined : { operation: { query: queryAccess } },
19
+ ui: navCount !== undefined ? { navCount } : undefined,
20
+ isSingleton,
21
+ } as ListConfig<TypeInfo>
22
+ }
23
+
24
+ /**
25
+ * A context whose `db[key].count` is a spy resolving to a fixed number, so we
26
+ * can assert both the returned counts and WHICH lists were queried.
27
+ */
28
+ function makeContext(counts: Record<string, number>): {
29
+ context: AccessContext
30
+ spies: Record<string, ReturnType<typeof vi.fn>>
31
+ } {
32
+ const spies: Record<string, ReturnType<typeof vi.fn>> = {}
33
+ const db: Record<string, { count: ReturnType<typeof vi.fn> }> = {}
34
+ for (const [dbKey, value] of Object.entries(counts)) {
35
+ const spy = vi.fn(async () => value)
36
+ spies[dbKey] = spy
37
+ db[dbKey] = { count: spy }
38
+ }
39
+ const context = {
40
+ db,
41
+ session: null,
42
+ prisma: {},
43
+ storage: {},
44
+ plugins: {},
45
+ _isSudo: false,
46
+ _resolveOutputCounter: { depth: 0 },
47
+ } as unknown as AccessContext
48
+ return { context, spies }
49
+ }
50
+
51
+ describe('isListQueryStaticallyDenied', () => {
52
+ it('is denied when query access is absent (deny-by-default)', () => {
53
+ expect(isListQueryStaticallyDenied(makeList({ queryAccess: undefined }))).toBe(true)
54
+ })
55
+
56
+ it('is denied when query access is the literal false', () => {
57
+ expect(isListQueryStaticallyDenied(makeList({ queryAccess: false }))).toBe(true)
58
+ })
59
+
60
+ it('is not denied when query access is the literal true', () => {
61
+ expect(isListQueryStaticallyDenied(makeList({ queryAccess: true }))).toBe(false)
62
+ })
63
+
64
+ it('is not denied when query access is a function (decided per request)', () => {
65
+ expect(isListQueryStaticallyDenied(makeList({ queryAccess: () => false }))).toBe(false)
66
+ })
67
+ })
68
+
69
+ describe('resolveNavCounts', () => {
70
+ it('counts only lists that opt in via ui.navCount', async () => {
71
+ const config: OpenSaasConfig = {
72
+ db: { provider: 'sqlite', url: 'file:./t.db' },
73
+ lists: {
74
+ Post: makeList({ queryAccess: true, navCount: true }),
75
+ Comment: makeList({ queryAccess: true, navCount: false }),
76
+ Tag: makeList({ queryAccess: true }), // no ui.navCount at all
77
+ },
78
+ }
79
+ const { context, spies } = makeContext({ post: 3, comment: 9, tag: 2 })
80
+
81
+ const counts = await resolveNavCounts(context, config)
82
+
83
+ expect(counts).toEqual({ Post: 3 })
84
+ // No count query runs for lists that didn't opt in.
85
+ expect(spies.post).toHaveBeenCalledTimes(1)
86
+ expect(spies.comment).not.toHaveBeenCalled()
87
+ expect(spies.tag).not.toHaveBeenCalled()
88
+ })
89
+
90
+ it('omits an opted-in list whose query access is statically denied (no misleading zero, no query)', async () => {
91
+ const config: OpenSaasConfig = {
92
+ db: { provider: 'sqlite', url: 'file:./t.db' },
93
+ lists: {
94
+ Secret: makeList({ queryAccess: false, navCount: true }),
95
+ Missing: makeList({ queryAccess: undefined, navCount: true }),
96
+ },
97
+ }
98
+ const { context, spies } = makeContext({ secret: 5, missing: 5 })
99
+
100
+ const counts = await resolveNavCounts(context, config)
101
+
102
+ expect(counts).toEqual({})
103
+ expect(spies.secret).not.toHaveBeenCalled()
104
+ expect(spies.missing).not.toHaveBeenCalled()
105
+ })
106
+
107
+ it('counts a list with function query access through the secured context', async () => {
108
+ const config: OpenSaasConfig = {
109
+ db: { provider: 'sqlite', url: 'file:./t.db' },
110
+ lists: {
111
+ Post: makeList({ queryAccess: () => true, navCount: true }),
112
+ },
113
+ }
114
+ const { context, spies } = makeContext({ post: 7 })
115
+
116
+ const counts = await resolveNavCounts(context, config)
117
+
118
+ expect(counts).toEqual({ Post: 7 })
119
+ expect(spies.post).toHaveBeenCalledTimes(1)
120
+ })
121
+
122
+ it('reports an access-scoped count of zero (session sees none) as 0, not omitted', async () => {
123
+ const config: OpenSaasConfig = {
124
+ db: { provider: 'sqlite', url: 'file:./t.db' },
125
+ lists: {
126
+ Post: makeList({ queryAccess: () => true, navCount: true }),
127
+ },
128
+ }
129
+ const { context } = makeContext({ post: 0 })
130
+
131
+ const counts = await resolveNavCounts(context, config)
132
+
133
+ expect(counts).toEqual({ Post: 0 })
134
+ })
135
+
136
+ it('skips singletons even when opted in (a single-record list has no meaningful count)', async () => {
137
+ const config: OpenSaasConfig = {
138
+ db: { provider: 'sqlite', url: 'file:./t.db' },
139
+ lists: {
140
+ SiteSettings: makeList({ queryAccess: true, navCount: true, isSingleton: true }),
141
+ },
142
+ }
143
+ const { context, spies } = makeContext({ siteSettings: 1 })
144
+
145
+ const counts = await resolveNavCounts(context, config)
146
+
147
+ expect(counts).toEqual({})
148
+ expect(spies.siteSettings).not.toHaveBeenCalled()
149
+ })
150
+
151
+ it("degrades gracefully when one list's count rejects — omits that badge, keeps the rest, never throws", async () => {
152
+ const config: OpenSaasConfig = {
153
+ db: { provider: 'sqlite', url: 'file:./t.db' },
154
+ lists: {
155
+ Post: makeList({ queryAccess: true, navCount: true }),
156
+ Comment: makeList({ queryAccess: true, navCount: true }),
157
+ },
158
+ }
159
+ // Post's count throws (e.g. a DB hiccup or a throwing access filter);
160
+ // Comment's succeeds. The whole admin chrome must still render.
161
+ const context = {
162
+ db: {
163
+ post: { count: async () => Promise.reject(new Error('db exploded')) },
164
+ comment: { count: async () => 4 },
165
+ },
166
+ session: null,
167
+ prisma: {},
168
+ storage: {},
169
+ plugins: {},
170
+ _isSudo: false,
171
+ _resolveOutputCounter: { depth: 0 },
172
+ } as unknown as AccessContext
173
+
174
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
175
+ try {
176
+ const counts = await resolveNavCounts(context, config)
177
+
178
+ // Rejecting list is omitted; the healthy list's count is unaffected.
179
+ expect(counts).toEqual({ Comment: 4 })
180
+ // The failure is logged, not swallowed silently.
181
+ expect(errorSpy).toHaveBeenCalledWith(
182
+ expect.stringContaining('Failed to resolve nav count for Post'),
183
+ expect.any(Error),
184
+ )
185
+ } finally {
186
+ errorSpy.mockRestore()
187
+ }
188
+ })
189
+ })