@opensaas/stack-core 0.37.0 → 0.39.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 (226) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +191 -0
  3. package/CLAUDE.md +42 -3
  4. package/dist/access/access-filter.d.ts +70 -113
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +183 -210
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +148 -188
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/declared-dependencies.d.ts +66 -26
  11. package/dist/access/declared-dependencies.d.ts.map +1 -1
  12. package/dist/access/declared-dependencies.js +62 -17
  13. package/dist/access/declared-dependencies.js.map +1 -1
  14. package/dist/access/declared-dependencies.test.d.ts +2 -0
  15. package/dist/access/declared-dependencies.test.d.ts.map +1 -0
  16. package/dist/access/declared-dependencies.test.js +226 -0
  17. package/dist/access/declared-dependencies.test.js.map +1 -0
  18. package/dist/access/depth-limits.d.ts +8 -7
  19. package/dist/access/depth-limits.d.ts.map +1 -1
  20. package/dist/access/depth-limits.js +8 -7
  21. package/dist/access/depth-limits.js.map +1 -1
  22. package/dist/access/engine.d.ts +0 -20
  23. package/dist/access/engine.d.ts.map +1 -1
  24. package/dist/access/engine.js +0 -25
  25. package/dist/access/engine.js.map +1 -1
  26. package/dist/access/errors.d.ts +61 -8
  27. package/dist/access/errors.d.ts.map +1 -1
  28. package/dist/access/errors.js +91 -12
  29. package/dist/access/errors.js.map +1 -1
  30. package/dist/access/field-access.d.ts +39 -1
  31. package/dist/access/field-access.d.ts.map +1 -1
  32. package/dist/access/field-access.js +108 -42
  33. package/dist/access/field-access.js.map +1 -1
  34. package/dist/access/field-access.test.js +229 -2
  35. package/dist/access/field-access.test.js.map +1 -1
  36. package/dist/access/field-transforms.d.ts +0 -27
  37. package/dist/access/field-transforms.d.ts.map +1 -1
  38. package/dist/access/field-visibility.d.ts +2 -5
  39. package/dist/access/field-visibility.d.ts.map +1 -1
  40. package/dist/access/field-visibility.js +89 -41
  41. package/dist/access/field-visibility.js.map +1 -1
  42. package/dist/access/index.d.ts +6 -3
  43. package/dist/access/index.d.ts.map +1 -1
  44. package/dist/access/index.js +12 -2
  45. package/dist/access/index.js.map +1 -1
  46. package/dist/access/query-validation.d.ts +121 -0
  47. package/dist/access/query-validation.d.ts.map +1 -0
  48. package/dist/access/query-validation.js +292 -0
  49. package/dist/access/query-validation.js.map +1 -0
  50. package/dist/access/relationship-count.d.ts +1 -1
  51. package/dist/access/relationship-count.d.ts.map +1 -1
  52. package/dist/access/relationship-count.js +2 -10
  53. package/dist/access/relationship-count.js.map +1 -1
  54. package/dist/access/relationship-label-filter.d.ts +23 -23
  55. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  56. package/dist/access/relationship-label-filter.js +5 -76
  57. package/dist/access/relationship-label-filter.js.map +1 -1
  58. package/dist/access/relationship-label-filter.test.js +19 -56
  59. package/dist/access/relationship-label-filter.test.js.map +1 -1
  60. package/dist/access/transaction-registry.d.ts +45 -0
  61. package/dist/access/transaction-registry.d.ts.map +1 -0
  62. package/dist/access/transaction-registry.js +37 -0
  63. package/dist/access/transaction-registry.js.map +1 -0
  64. package/dist/access/types.d.ts +55 -59
  65. package/dist/access/types.d.ts.map +1 -1
  66. package/dist/config/index.d.ts +9 -7
  67. package/dist/config/index.d.ts.map +1 -1
  68. package/dist/config/index.js +10 -18
  69. package/dist/config/index.js.map +1 -1
  70. package/dist/config/label.d.ts +6 -13
  71. package/dist/config/label.d.ts.map +1 -1
  72. package/dist/config/label.js +6 -13
  73. package/dist/config/label.js.map +1 -1
  74. package/dist/config/nav-count.d.ts +11 -19
  75. package/dist/config/nav-count.d.ts.map +1 -1
  76. package/dist/config/nav-count.js +11 -19
  77. package/dist/config/nav-count.js.map +1 -1
  78. package/dist/config/plugin-engine.d.ts +0 -10
  79. package/dist/config/plugin-engine.d.ts.map +1 -1
  80. package/dist/config/plugin-engine.js +1 -37
  81. package/dist/config/plugin-engine.js.map +1 -1
  82. package/dist/config/types.d.ts +94 -109
  83. package/dist/config/types.d.ts.map +1 -1
  84. package/dist/context/apply-defaults.d.ts +8 -31
  85. package/dist/context/apply-defaults.d.ts.map +1 -1
  86. package/dist/context/apply-defaults.js +12 -41
  87. package/dist/context/apply-defaults.js.map +1 -1
  88. package/dist/context/hook-pipeline.d.ts +4 -29
  89. package/dist/context/hook-pipeline.d.ts.map +1 -1
  90. package/dist/context/hook-pipeline.js +8 -41
  91. package/dist/context/hook-pipeline.js.map +1 -1
  92. package/dist/context/index.d.ts +4 -15
  93. package/dist/context/index.d.ts.map +1 -1
  94. package/dist/context/index.js +223 -215
  95. package/dist/context/index.js.map +1 -1
  96. package/dist/context/nested-operations.d.ts +7 -26
  97. package/dist/context/nested-operations.d.ts.map +1 -1
  98. package/dist/context/nested-operations.js +73 -171
  99. package/dist/context/nested-operations.js.map +1 -1
  100. package/dist/context/transaction-boundary.d.ts +36 -47
  101. package/dist/context/transaction-boundary.d.ts.map +1 -1
  102. package/dist/context/transaction-boundary.js +92 -79
  103. package/dist/context/transaction-boundary.js.map +1 -1
  104. package/dist/context/write-pipeline.d.ts +25 -64
  105. package/dist/context/write-pipeline.d.ts.map +1 -1
  106. package/dist/context/write-pipeline.js +81 -111
  107. package/dist/context/write-pipeline.js.map +1 -1
  108. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  109. package/dist/fields/format-prisma-default.js +4 -7
  110. package/dist/fields/format-prisma-default.js.map +1 -1
  111. package/dist/fields/index.d.ts +17 -2
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +136 -147
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/filter/collect.d.ts +26 -3
  116. package/dist/filter/collect.d.ts.map +1 -1
  117. package/dist/filter/collect.js +30 -12
  118. package/dist/filter/collect.js.map +1 -1
  119. package/dist/filter/filter.test.js +98 -15
  120. package/dist/filter/filter.test.js.map +1 -1
  121. package/dist/filter/index.d.ts +1 -0
  122. package/dist/filter/index.d.ts.map +1 -1
  123. package/dist/filter/index.js.map +1 -1
  124. package/dist/filter/map.d.ts.map +1 -1
  125. package/dist/filter/map.js +0 -5
  126. package/dist/filter/map.js.map +1 -1
  127. package/dist/filter/parse.d.ts.map +1 -1
  128. package/dist/filter/parse.js +2 -9
  129. package/dist/filter/parse.js.map +1 -1
  130. package/dist/filter/serialize.d.ts.map +1 -1
  131. package/dist/filter/serialize.js +2 -4
  132. package/dist/filter/serialize.js.map +1 -1
  133. package/dist/hooks/index.d.ts +4 -42
  134. package/dist/hooks/index.d.ts.map +1 -1
  135. package/dist/hooks/index.js +5 -59
  136. package/dist/hooks/index.js.map +1 -1
  137. package/dist/index.d.ts +3 -1
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +20 -4
  140. package/dist/index.js.map +1 -1
  141. package/dist/internal.d.ts +1 -0
  142. package/dist/internal.d.ts.map +1 -1
  143. package/dist/internal.js +4 -0
  144. package/dist/internal.js.map +1 -1
  145. package/dist/lib/case-utils.d.ts +0 -32
  146. package/dist/lib/case-utils.d.ts.map +1 -1
  147. package/dist/lib/case-utils.js +0 -32
  148. package/dist/lib/case-utils.js.map +1 -1
  149. package/dist/mcp/handler.d.ts +0 -4
  150. package/dist/mcp/handler.d.ts.map +1 -1
  151. package/dist/mcp/handler.js +12 -69
  152. package/dist/mcp/handler.js.map +1 -1
  153. package/dist/query/index.d.ts +55 -227
  154. package/dist/query/index.d.ts.map +1 -1
  155. package/dist/query/index.js +41 -117
  156. package/dist/query/index.js.map +1 -1
  157. package/dist/query/relationship-options.d.ts +1 -1
  158. package/dist/query/relationship-options.js +1 -1
  159. package/dist/utils/password.d.ts +0 -68
  160. package/dist/utils/password.d.ts.map +1 -1
  161. package/dist/utils/password.js +7 -76
  162. package/dist/utils/password.js.map +1 -1
  163. package/dist/validation/field-config.d.ts.map +1 -1
  164. package/dist/validation/field-config.js +0 -6
  165. package/dist/validation/field-config.js.map +1 -1
  166. package/dist/validation/schema.d.ts +0 -7
  167. package/dist/validation/schema.d.ts.map +1 -1
  168. package/dist/validation/schema.js +0 -10
  169. package/dist/validation/schema.js.map +1 -1
  170. package/package.json +1 -1
  171. package/src/access/access-filter.test.ts +205 -275
  172. package/src/access/access-filter.ts +236 -272
  173. package/src/access/declared-dependencies.test.ts +277 -0
  174. package/src/access/declared-dependencies.ts +117 -37
  175. package/src/access/depth-limits.ts +8 -7
  176. package/src/access/engine.ts +0 -25
  177. package/src/access/errors.ts +97 -12
  178. package/src/access/field-access.test.ts +312 -2
  179. package/src/access/field-access.ts +118 -42
  180. package/src/access/field-transforms.ts +8 -39
  181. package/src/access/field-visibility.ts +99 -45
  182. package/src/access/index.ts +17 -5
  183. package/src/access/query-validation.ts +359 -0
  184. package/src/access/relationship-count.ts +3 -11
  185. package/src/access/relationship-label-filter.test.ts +19 -68
  186. package/src/access/relationship-label-filter.ts +27 -104
  187. package/src/access/transaction-registry.ts +58 -0
  188. package/src/access/types.ts +83 -86
  189. package/src/config/index.ts +10 -22
  190. package/src/config/label.ts +6 -13
  191. package/src/config/nav-count.ts +11 -19
  192. package/src/config/plugin-engine.ts +1 -37
  193. package/src/config/types.ts +95 -110
  194. package/src/context/apply-defaults.ts +12 -44
  195. package/src/context/hook-pipeline.ts +12 -67
  196. package/src/context/index.ts +311 -276
  197. package/src/context/nested-operations.ts +94 -226
  198. package/src/context/transaction-boundary.ts +113 -109
  199. package/src/context/write-pipeline.ts +99 -155
  200. package/src/fields/format-prisma-default.ts +4 -7
  201. package/src/fields/index.ts +155 -147
  202. package/src/filter/collect.ts +48 -12
  203. package/src/filter/filter.test.ts +153 -15
  204. package/src/filter/index.ts +1 -0
  205. package/src/filter/map.ts +0 -5
  206. package/src/filter/parse.ts +2 -9
  207. package/src/filter/serialize.ts +2 -4
  208. package/src/hooks/index.ts +5 -59
  209. package/src/index.ts +23 -4
  210. package/src/internal.ts +5 -0
  211. package/src/lib/case-utils.ts +0 -32
  212. package/src/mcp/handler.ts +13 -71
  213. package/src/query/index.ts +82 -235
  214. package/src/query/relationship-options.ts +1 -1
  215. package/src/utils/password.ts +7 -76
  216. package/src/validation/field-config.ts +0 -6
  217. package/src/validation/schema.ts +0 -10
  218. package/tests/access-relationships.test.ts +18 -16
  219. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  220. package/tests/context.test.ts +727 -1
  221. package/tests/field-types.test.ts +222 -0
  222. package/tests/mcp-handler.test.ts +34 -0
  223. package/tests/needs-declared-dependencies.test.ts +7 -4
  224. package/tests/resolve-chain.test.ts +11 -11
  225. package/tests/transaction-boundary-outermost.test.ts +530 -0
  226. package/tsconfig.tsbuildinfo +1 -1
@@ -1,31 +1,65 @@
1
1
  import type { ListConfig, OpenSaasConfig } from '../config/types.js'
2
+ import type { Session, AccessContext } from '../access/types.js'
3
+ import { isFieldReadableForPredicate } from '../access/field-access.js'
2
4
  import { parseFilterQuery } from './parse.js'
3
5
  import { buildFilterWhere } from './map.js'
4
6
  import type { FilterCondition, FilterFieldSuggestion, FilterSpec } from './types.js'
5
7
 
8
+ /**
9
+ * The session/context a field's `read` access is evaluated against — the same
10
+ * shape every other access-scoped admin-UI helper takes (e.g.
11
+ * `resolveRelationshipCountFilters`, `resolveRelationshipLabelFilters`).
12
+ */
13
+ export type FilterAccessArgs = {
14
+ session: Session | null
15
+ context: AccessContext & { _isSudo?: boolean }
16
+ }
17
+
6
18
  /**
7
19
  * Resolve every field's {@link FilterSpec} for a list by delegating to each
8
20
  * field's optional `getFilterSpec` method. A field without the method (or one
9
21
  * whose method returns `undefined`) is simply not filterable — the absence
10
22
  * degrades gracefully so third-party fields keep working.
11
23
  *
24
+ * A field the session cannot READ is excluded here too (#915), evaluated the
25
+ * same predicate-time way `context.db.*`'s `findMany`/`count` now enforce
26
+ * (`isFieldReadableForPredicate` — no fetched row exists yet, so a
27
+ * row-dependent `read` rule resolves to "not filterable"). This is what keeps
28
+ * the admin UI from ever suggesting, autocompleting, or submitting a filter
29
+ * the engine is going to reject: excluding the spec here means a token
30
+ * naming that field degrades to free text (`buildFilterWhere`'s existing
31
+ * "unknown field" path) rather than reaching `context.db` at all.
32
+ *
12
33
  * @param listConfig The list whose fields to inspect.
13
34
  * @param listKey The list's key (passed through to each field's spec).
14
35
  * @param config The full config (relationship specs resolve their target
15
36
  * list's label field from it).
37
+ * @param args The session/context to evaluate field-level `read` access
38
+ * against.
16
39
  */
17
- export function collectFilterSpecs(
40
+ export async function collectFilterSpecs(
18
41
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
19
42
  listConfig: ListConfig<any>,
20
43
  listKey: string,
21
44
  config: OpenSaasConfig,
22
- ): Record<string, FilterSpec> {
45
+ args: FilterAccessArgs,
46
+ ): Promise<Record<string, FilterSpec>> {
23
47
  const specs: Record<string, FilterSpec> = {}
24
- for (const [fieldName, field] of Object.entries(listConfig.fields)) {
25
- if (typeof field.getFilterSpec !== 'function') continue
26
- const spec = field.getFilterSpec(fieldName, listKey, config)
48
+ const filterableFields = Object.entries(listConfig.fields).filter(
49
+ ([, field]) => typeof field.getFilterSpec === 'function',
50
+ )
51
+ // Each field's read-access check is independent of every other's — run
52
+ // them concurrently rather than serially, since a `read` rule that does
53
+ // async work (e.g. a DB lookup) would otherwise add per-field latency to
54
+ // every list-view page render (found in review of #925).
55
+ const readableFlags = await Promise.all(
56
+ filterableFields.map(([, field]) => isFieldReadableForPredicate(field.access, args)),
57
+ )
58
+ filterableFields.forEach(([fieldName, field], index) => {
59
+ if (!readableFlags[index]) return
60
+ const spec = field.getFilterSpec!(fieldName, listKey, config)
27
61
  if (spec) specs[fieldName] = spec
28
- }
62
+ })
29
63
  return specs
30
64
  }
31
65
 
@@ -38,14 +72,15 @@ export function collectFilterSpecs(
38
72
  *
39
73
  * @returns A `where` fragment, or `undefined` when the query filters nothing.
40
74
  */
41
- export function buildListFilterWhere(
75
+ export async function buildListFilterWhere(
42
76
  query: string,
43
77
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
44
78
  listConfig: ListConfig<any>,
45
79
  listKey: string,
46
80
  config: OpenSaasConfig,
47
- ): FilterCondition | undefined {
48
- const specs = collectFilterSpecs(listConfig, listKey, config)
81
+ args: FilterAccessArgs,
82
+ ): Promise<FilterCondition | undefined> {
83
+ const specs = await collectFilterSpecs(listConfig, listKey, config, args)
49
84
  const tokens = parseFilterQuery(query)
50
85
  return buildFilterWhere(tokens, specs)
51
86
  }
@@ -56,13 +91,14 @@ export function buildListFilterWhere(
56
91
  * search). Carries no functions, so it can cross the server/client boundary to
57
92
  * drive the Filter builder's autocomplete.
58
93
  */
59
- export function collectFilterSuggestions(
94
+ export async function collectFilterSuggestions(
60
95
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
61
96
  listConfig: ListConfig<any>,
62
97
  listKey: string,
63
98
  config: OpenSaasConfig,
64
- ): FilterFieldSuggestion[] {
65
- const specs = collectFilterSpecs(listConfig, listKey, config)
99
+ args: FilterAccessArgs,
100
+ ): Promise<FilterFieldSuggestion[]> {
101
+ const specs = await collectFilterSpecs(listConfig, listKey, config, args)
66
102
  return Object.entries(specs).map(([field, spec]) => ({
67
103
  field,
68
104
  operators: spec.operators,
@@ -1,11 +1,34 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { parseFilterQuery } from './parse.js'
3
3
  import { buildFilterWhere } from './map.js'
4
- import { buildListFilterWhere, collectFilterSpecs, collectFilterSuggestions } from './collect.js'
4
+ import {
5
+ buildListFilterWhere,
6
+ collectFilterSpecs,
7
+ collectFilterSuggestions,
8
+ type FilterAccessArgs,
9
+ } from './collect.js'
5
10
  import type { FilterSpec } from './types.js'
6
11
  import { list } from '../config/index.js'
7
- import { text, integer, select, checkbox, timestamp, relationship } from '../fields/index.js'
12
+ import {
13
+ text,
14
+ integer,
15
+ bigInt,
16
+ select,
17
+ checkbox,
18
+ timestamp,
19
+ relationship,
20
+ } from '../fields/index.js'
8
21
  import type { OpenSaasConfig } from '../config/types.js'
22
+ import type { AccessContext } from '../access/types.js'
23
+
24
+ // A permissive session/context — no field on any list under test declares
25
+ // field-level `read` access unless a test says so, so every field is
26
+ // filterable/sortable by default (matching `checkFieldAccess`'s own
27
+ // allow-by-default for a field with no rule).
28
+ const noAccessArgs: FilterAccessArgs = {
29
+ session: null,
30
+ context: { _isSudo: false } as unknown as AccessContext,
31
+ }
9
32
 
10
33
  // ─────────────────────────────────────────────────────────────
11
34
  // parseFilterQuery — the pure query-string → tokens boundary (ADR-0017)
@@ -231,6 +254,7 @@ function makeConfig(): OpenSaasConfig {
231
254
  fields: {
232
255
  title: text(),
233
256
  views: integer(),
257
+ occurredAtMs: bigInt(),
234
258
  status: select({
235
259
  options: [
236
260
  { label: 'Draft', value: 'draft' },
@@ -250,10 +274,10 @@ describe('core field Filter specs', () => {
250
274
  const config = makeConfig()
251
275
  const postConfig = config.lists.Post
252
276
 
253
- it('collectFilterSpecs resolves a spec for every filterable core field', () => {
254
- const collected = collectFilterSpecs(postConfig, 'Post', config)
277
+ it('collectFilterSpecs resolves a spec for every filterable core field', async () => {
278
+ const collected = await collectFilterSpecs(postConfig, 'Post', config, noAccessArgs)
255
279
  expect(Object.keys(collected).sort()).toEqual(
256
- ['author', 'featured', 'publishedAt', 'status', 'title', 'views'].sort(),
280
+ ['author', 'featured', 'occurredAtMs', 'publishedAt', 'status', 'title', 'views'].sort(),
257
281
  )
258
282
  })
259
283
 
@@ -270,6 +294,16 @@ describe('core field Filter specs', () => {
270
294
  expect(spec.toCondition('eq', 'abc')).toBeNull()
271
295
  })
272
296
 
297
+ it('bigInt supports comparisons, emits a bigint condition, and rejects non-integers', () => {
298
+ const spec = postConfig.fields.occurredAtMs.getFilterSpec!('occurredAtMs', 'Post', config)!
299
+ expect(spec.toCondition('gt', '9007199254740993')).toEqual({
300
+ occurredAtMs: { gt: 9007199254740993n },
301
+ })
302
+ expect(spec.toCondition('eq', '5')).toEqual({ occurredAtMs: { equals: 5n } })
303
+ expect(spec.toCondition('eq', 'abc')).toBeNull()
304
+ expect(spec.toCondition('eq', '1.5')).toBeNull()
305
+ })
306
+
273
307
  it('select matches by value or label and resolves to the stored value', () => {
274
308
  const spec = postConfig.fields.status.getFilterSpec!('status', 'Post', config)!
275
309
  expect(spec.toCondition('eq', 'Published')).toEqual({ status: { equals: 'published' } })
@@ -309,23 +343,68 @@ describe('core field Filter specs', () => {
309
343
  })
310
344
  })
311
345
 
312
- it('does not produce specs for password/json/virtual (absence degrades gracefully)', () => {
346
+ it('does not produce specs for password/json/virtual (absence degrades gracefully)', async () => {
313
347
  // A list of non-filterable fields yields no specs at all.
314
- const specsForUser = collectFilterSpecs(config.lists.User, 'User', config)
348
+ const specsForUser = await collectFilterSpecs(config.lists.User, 'User', config, noAccessArgs)
315
349
  // User only has `name` (text) → exactly one spec, proving non-text absence.
316
350
  expect(Object.keys(specsForUser)).toEqual(['name'])
317
351
  })
352
+
353
+ it('excludes a field the session cannot read (#915)', async () => {
354
+ const gatedConfig: OpenSaasConfig = {
355
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
356
+ lists: {
357
+ Organisation: list({
358
+ fields: {
359
+ name: text(),
360
+ billingAddress: text({ access: { read: () => false } }),
361
+ },
362
+ }),
363
+ },
364
+ }
365
+ const collected = await collectFilterSpecs(
366
+ gatedConfig.lists.Organisation,
367
+ 'Organisation',
368
+ gatedConfig,
369
+ noAccessArgs,
370
+ )
371
+ expect(Object.keys(collected)).toEqual(['name'])
372
+ })
373
+
374
+ it('excludes a row-dependent read rule too — no row exists at predicate time (#915)', async () => {
375
+ const gatedConfig: OpenSaasConfig = {
376
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
377
+ lists: {
378
+ Organisation: list({
379
+ fields: {
380
+ name: text(),
381
+ billingAddress: text({
382
+ access: { read: ({ item, session }) => item.ownerId === session?.userId },
383
+ }),
384
+ },
385
+ }),
386
+ },
387
+ }
388
+ const collected = await collectFilterSpecs(
389
+ gatedConfig.lists.Organisation,
390
+ 'Organisation',
391
+ gatedConfig,
392
+ noAccessArgs,
393
+ )
394
+ expect(Object.keys(collected)).toEqual(['name'])
395
+ })
318
396
  })
319
397
 
320
398
  describe('buildListFilterWhere (end-to-end over a real list config)', () => {
321
399
  const config = makeConfig()
322
400
 
323
- it('composes parse + specs into a merged where', () => {
324
- const where = buildListFilterWhere(
401
+ it('composes parse + specs into a merged where', async () => {
402
+ const where = await buildListFilterWhere(
325
403
  'status:Published views:>10 author:"Ada" hello',
326
404
  config.lists.Post,
327
405
  'Post',
328
406
  config,
407
+ noAccessArgs,
329
408
  )
330
409
  expect(where).toEqual({
331
410
  AND: [
@@ -339,8 +418,35 @@ describe('buildListFilterWhere (end-to-end over a real list config)', () => {
339
418
  })
340
419
  })
341
420
 
342
- it('returns undefined for an all-whitespace query', () => {
343
- expect(buildListFilterWhere(' ', config.lists.Post, 'Post', config)).toBeUndefined()
421
+ it('returns undefined for an all-whitespace query', async () => {
422
+ expect(
423
+ await buildListFilterWhere(' ', config.lists.Post, 'Post', config, noAccessArgs),
424
+ ).toBeUndefined()
425
+ })
426
+
427
+ it('a read-denied field degrades to free text instead of reaching the engine (#915)', async () => {
428
+ const gatedConfig: OpenSaasConfig = {
429
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
430
+ lists: {
431
+ Organisation: list({
432
+ fields: {
433
+ name: text(),
434
+ billingAddress: text({ access: { read: () => false } }),
435
+ },
436
+ }),
437
+ },
438
+ }
439
+ // `billingAddress:12` never produces `{ billingAddress: { contains: '12' } }`
440
+ // — the spec is excluded, so the token degrades to a free-text search for
441
+ // "12" across `name` (the only remaining free-text field) instead.
442
+ const where = await buildListFilterWhere(
443
+ 'billingAddress:12',
444
+ gatedConfig.lists.Organisation,
445
+ 'Organisation',
446
+ gatedConfig,
447
+ noAccessArgs,
448
+ )
449
+ expect(where).toEqual({ name: { contains: '12' } })
344
450
  })
345
451
  })
346
452
 
@@ -379,17 +485,28 @@ describe('to-many relationship Filter spec (count comparisons — issue #732)',
379
485
  expect(spec.suggestions.valueSource).toEqual({ kind: 'none' })
380
486
  })
381
487
 
382
- it('end-to-end: buildListFilterWhere carries a count marker for `posts:>5`', () => {
488
+ it('end-to-end: buildListFilterWhere carries a count marker for `posts:>5`', async () => {
383
489
  const config = countConfig()
384
- const where = buildListFilterWhere('posts:>5', config.lists.User, 'User', config)
490
+ const where = await buildListFilterWhere(
491
+ 'posts:>5',
492
+ config.lists.User,
493
+ 'User',
494
+ config,
495
+ noAccessArgs,
496
+ )
385
497
  expect(where).toEqual({ posts: { _countFilter: { operator: 'gt', value: 5 } } })
386
498
  })
387
499
  })
388
500
 
389
501
  describe('collectFilterSuggestions', () => {
390
- it('returns serializable, function-free suggestion metadata', () => {
502
+ it('returns serializable, function-free suggestion metadata', async () => {
391
503
  const config = makeConfig()
392
- const suggestions = collectFilterSuggestions(config.lists.Post, 'Post', config)
504
+ const suggestions = await collectFilterSuggestions(
505
+ config.lists.Post,
506
+ 'Post',
507
+ config,
508
+ noAccessArgs,
509
+ )
393
510
  // No functions anywhere → JSON round-trips cleanly.
394
511
  expect(JSON.parse(JSON.stringify(suggestions))).toEqual(suggestions)
395
512
  const status = suggestions.find((s) => s.field === 'status')!
@@ -397,4 +514,25 @@ describe('collectFilterSuggestions', () => {
397
514
  const author = suggestions.find((s) => s.field === 'author')!
398
515
  expect(author.valueSource).toEqual({ kind: 'relationship', listKey: 'User', many: false })
399
516
  })
517
+
518
+ it('excludes a read-denied field from the suggestion metadata (#915)', async () => {
519
+ const gatedConfig: OpenSaasConfig = {
520
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
521
+ lists: {
522
+ Organisation: list({
523
+ fields: {
524
+ name: text(),
525
+ billingAddress: text({ access: { read: () => false } }),
526
+ },
527
+ }),
528
+ },
529
+ }
530
+ const suggestions = await collectFilterSuggestions(
531
+ gatedConfig.lists.Organisation,
532
+ 'Organisation',
533
+ gatedConfig,
534
+ noAccessArgs,
535
+ )
536
+ expect(suggestions.map((s) => s.field)).toEqual(['name'])
537
+ })
400
538
  })
@@ -13,6 +13,7 @@ export { parseFilterQuery } from './parse.js'
13
13
  export { serializeFilterQuery } from './serialize.js'
14
14
  export { buildFilterWhere } from './map.js'
15
15
  export { collectFilterSpecs, buildListFilterWhere, collectFilterSuggestions } from './collect.js'
16
+ export type { FilterAccessArgs } from './collect.js'
16
17
  export { RELATIONSHIP_COUNT_FILTER_KEY } from './types.js'
17
18
  export type {
18
19
  FilterOperator,
package/src/filter/map.ts CHANGED
@@ -25,7 +25,6 @@ export function buildFilterWhere(
25
25
  const freeTextFields = Object.keys(specs).filter((field) => specs[field].freeText)
26
26
 
27
27
  for (const token of tokens) {
28
- // Bare free-text word.
29
28
  if (token.field === null) {
30
29
  if (token.value) freeTextWords.push(token.value)
31
30
  continue
@@ -33,8 +32,6 @@ export function buildFilterWhere(
33
32
 
34
33
  const spec = specs[token.field]
35
34
 
36
- // Unknown field / no spec / unsupported operator / empty value →
37
- // degrade to free text (search the value across free-text fields).
38
35
  if (!spec || !spec.operators.includes(token.operator) || token.value === '') {
39
36
  if (token.value) freeTextWords.push(token.value)
40
37
  continue
@@ -42,7 +39,6 @@ export function buildFilterWhere(
42
39
 
43
40
  const condition = spec.toCondition(token.operator, token.value)
44
41
  if (condition === null) {
45
- // Value couldn't be interpreted for this field → free text.
46
42
  freeTextWords.push(token.value)
47
43
  continue
48
44
  }
@@ -50,7 +46,6 @@ export function buildFilterWhere(
50
46
  andConditions.push(condition)
51
47
  }
52
48
 
53
- // Each free-text word: OR across every free-text field's `eq` mapping.
54
49
  if (freeTextFields.length > 0) {
55
50
  for (const word of freeTextWords) {
56
51
  const orConditions = freeTextFields
@@ -48,15 +48,11 @@ export function parseFilterQuery(query: string): FilterToken[] {
48
48
  let i = 0
49
49
 
50
50
  while (i < n) {
51
- // Skip leading whitespace between tokens.
52
51
  while (i < n && isWhitespace(query[i])) i++
53
52
  if (i >= n) break
54
53
 
55
54
  const start = i
56
55
 
57
- // Optional `field:` prefix. A URL scheme (`http://…`) is not a field
58
- // prefix: when the colon is immediately followed by `//`, keep the whole
59
- // token as free text so pasted URLs are searched verbatim.
60
56
  let field: string | null = null
61
57
  const prefixMatch = FIELD_PREFIX_RE.exec(query.slice(i))
62
58
  if (prefixMatch && !query.startsWith('//', i + prefixMatch[0].length)) {
@@ -64,8 +60,6 @@ export function parseFilterQuery(query: string): FilterToken[] {
64
60
  i += prefixMatch[0].length
65
61
  }
66
62
 
67
- // Comparison operator prefix — only meaningful once a field is present.
68
- // A leading `>`/`<` on a bare word is left as part of the free-text value.
69
63
  let operator: FilterOperator = 'eq'
70
64
  if (field) {
71
65
  if (query.startsWith('>=', i)) {
@@ -83,15 +77,14 @@ export function parseFilterQuery(query: string): FilterToken[] {
83
77
  }
84
78
  }
85
79
 
86
- // Value: quoted (spaces kept) or a run up to the next whitespace.
87
80
  let value = ''
88
81
  if (query[i] === '"') {
89
- i++ // opening quote
82
+ i++
90
83
  while (i < n && query[i] !== '"') {
91
84
  value += query[i]
92
85
  i++
93
86
  }
94
- if (i < n) i++ // closing quote
87
+ if (i < n) i++
95
88
  } else {
96
89
  while (i < n && !isWhitespace(query[i])) {
97
90
  value += query[i]
@@ -56,13 +56,11 @@ export function serializeFilterQuery(tokens: FilterToken[]): string {
56
56
  const parts: string[] = []
57
57
 
58
58
  for (const token of tokens) {
59
- // An empty value is not a filter — skip it rather than emit `field:` / `""`.
60
59
  if (token.value === '') continue
61
60
 
62
61
  if (token.field === null) {
63
- // Bare free-text word. A leading `>`/`<` stays literal for bare words
64
- // (the parser only treats them as operators after a `field:`), so no
65
- // operator guard is needed here.
62
+ // A leading `>`/`<` stays literal for a bare word (the parser only
63
+ // treats them as operators after a `field:`), so no operator guard here.
66
64
  parts.push(quoteIfNeeded(token.value, false))
67
65
  continue
68
66
  }
@@ -4,9 +4,6 @@ import type { FieldConfig } from '../config/types.js'
4
4
  import { validateWithZod } from '../validation/schema.js'
5
5
  import { checkFieldAccess } from '../access/field-access.js'
6
6
 
7
- /**
8
- * Validation error collection
9
- */
10
7
  export class ValidationError extends Error {
11
8
  public errors: string[]
12
9
  public fieldErrors: Record<string, string>
@@ -19,10 +16,7 @@ export class ValidationError extends Error {
19
16
  }
20
17
  }
21
18
 
22
- /**
23
- * Database error with field-specific error information
24
- * Used for Prisma errors like unique constraint violations
25
- */
19
+ /** Used for Prisma errors like unique constraint violations. */
26
20
  export class DatabaseError extends Error {
27
21
  public fieldErrors: Record<string, string>
28
22
  public code?: string
@@ -35,10 +29,6 @@ export class DatabaseError extends Error {
35
29
  }
36
30
  }
37
31
 
38
- /**
39
- * Execute resolveInput hook
40
- * Allows modification of input data before validation
41
- */
42
32
  export async function executeResolveInput<
43
33
  TOutput = Record<string, unknown>,
44
34
  TCreateInput = Record<string, unknown>,
@@ -71,10 +61,7 @@ export async function executeResolveInput<
71
61
  return result
72
62
  }
73
63
 
74
- /**
75
- * Execute validate hook (supports both 'validate' and deprecated 'validateInput')
76
- * Allows custom validation logic
77
- */
64
+ /** Supports both `validate` and the deprecated `validateInput` alias for backwards compatibility. */
78
65
  export async function executeValidate<
79
66
  TOutput = Record<string, unknown>,
80
67
  TCreateInput = Record<string, unknown>,
@@ -105,7 +92,6 @@ export async function executeValidate<
105
92
  context: AccessContext
106
93
  },
107
94
  ): Promise<void> {
108
- // Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
109
95
  const validateHook = hooks?.validate || hooks?.validateInput
110
96
  if (!validateHook) {
111
97
  return
@@ -132,10 +118,7 @@ export async function executeValidate<
132
118
  */
133
119
  export const executeValidateInput = executeValidate
134
120
 
135
- /**
136
- * Execute beforeOperation hook
137
- * Runs before database operation (cannot modify data)
138
- */
121
+ /** Side effects only — cannot modify data before the database write. */
139
122
  export async function executeBeforeOperation<
140
123
  TOutput = Record<string, unknown>,
141
124
  TCreateInput = Record<string, unknown>,
@@ -172,10 +155,6 @@ export async function executeBeforeOperation<
172
155
  await hooks.beforeOperation(args as Parameters<typeof hooks.beforeOperation>[0])
173
156
  }
174
157
 
175
- /**
176
- * Execute afterOperation hook
177
- * Runs after database operation
178
- */
179
158
  export async function executeAfterOperation<
180
159
  TOutput = Record<string, unknown>,
181
160
  TCreateInput = Record<string, unknown>,
@@ -410,7 +389,6 @@ export async function executeFieldAfterTransactionHooks(
410
389
  isTopLevel: boolean,
411
390
  originalItem?: Record<string, unknown>,
412
391
  ): Promise<void> {
413
- // The persisted/pre-write rows are surfaced only for the top-level list.
414
392
  const committedItem = outcome.status === 'committed' && isTopLevel ? outcome.item : undefined
415
393
  const committedOriginalItem = isTopLevel ? originalItem : undefined
416
394
 
@@ -449,7 +427,6 @@ export async function executeFieldAfterTransactionHooks(
449
427
  continue
450
428
  }
451
429
 
452
- // committed
453
430
  if (operation === 'delete') {
454
431
  await fieldConfig.hooks.afterTransaction({
455
432
  ...base,
@@ -479,9 +456,6 @@ export async function executeFieldAfterTransactionHooks(
479
456
  }
480
457
 
481
458
  /**
482
- * Execute field-level resolveInput hooks
483
- * Allows fields to transform their input values before database write
484
- *
485
459
  * NOTE (#789): multi-column fields (e.g. storage image()/file() in
486
460
  * Keystone-parity mode) are NOT split here. This phase only resolves each
487
461
  * field's value under its LOGICAL key, so that phases 2-3 (list/field
@@ -506,11 +480,9 @@ export async function executeFieldResolveInputHooks(
506
480
  let result = { ...resolvedData }
507
481
 
508
482
  for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
509
- // Skip if field not in data, or if there's nothing to resolve
510
483
  if (!(fieldKey in result)) continue
511
484
  if (!fieldConfig.hooks?.resolveInput) continue
512
485
 
513
- // Execute field hook
514
486
  // Type assertion is safe here because hooks are typed correctly in field definitions
515
487
  // and we're working with runtime values that match those types
516
488
  const resolvedValue = await fieldConfig.hooks.resolveInput({
@@ -595,10 +567,7 @@ export async function splitMultiColumnFields(
595
567
  return result
596
568
  }
597
569
 
598
- /**
599
- * Execute field-level validate hooks
600
- * Allows fields to perform custom validation after resolveInput but before database write
601
- */
570
+ /** Runs after resolveInput and before the database write. */
602
571
  export async function executeFieldValidateHooks(
603
572
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
604
573
  inputData: Record<string, any> | undefined,
@@ -620,11 +589,10 @@ export async function executeFieldValidateHooks(
620
589
  }
621
590
 
622
591
  for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
623
- // Support both 'validate' (new) and 'validateInput' (deprecated) for backwards compatibility
592
+ // validate / deprecated validateInput fallback see executeValidate
624
593
  const validateHook = fieldConfig.hooks?.validate ?? fieldConfig.hooks?.validateInput
625
594
  if (!validateHook) continue
626
595
 
627
- // Execute field hook
628
596
  // Type assertion is safe here because hooks are typed correctly in field definitions
629
597
  if (operation === 'delete') {
630
598
  await validateHook({
@@ -647,7 +615,6 @@ export async function executeFieldValidateHooks(
647
615
  addValidationError: addValidationError(fieldKey),
648
616
  } as Parameters<typeof validateHook>[0])
649
617
  } else {
650
- // operation === 'update'
651
618
  await validateHook({
652
619
  listKey,
653
620
  fieldKey,
@@ -666,10 +633,6 @@ export async function executeFieldValidateHooks(
666
633
  }
667
634
  }
668
635
 
669
- /**
670
- * Execute field-level beforeOperation hooks (side effects only)
671
- * Allows fields to perform side effects before database write
672
- */
673
636
  export async function executeFieldBeforeOperationHooks(
674
637
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
675
638
  inputData: Record<string, any>,
@@ -683,13 +646,9 @@ export async function executeFieldBeforeOperationHooks(
683
646
  item?: any,
684
647
  ): Promise<void> {
685
648
  for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
686
- // Skip if no hooks defined
687
649
  if (!fieldConfig.hooks?.beforeOperation) continue
688
- // Skip if field not in data (for create/update)
689
650
  if (operation !== 'delete' && !(fieldKey in resolvedData)) continue
690
651
 
691
- // Execute field hook (side effects only, no return value used)
692
- // Type assertion is safe here because hooks are typed correctly in field definitions
693
652
  if (operation === 'delete') {
694
653
  await fieldConfig.hooks.beforeOperation({
695
654
  listKey,
@@ -708,7 +667,6 @@ export async function executeFieldBeforeOperationHooks(
708
667
  context,
709
668
  } as Parameters<typeof fieldConfig.hooks.beforeOperation>[0])
710
669
  } else {
711
- // operation === 'update'
712
670
  await fieldConfig.hooks.beforeOperation({
713
671
  listKey,
714
672
  fieldKey,
@@ -722,10 +680,6 @@ export async function executeFieldBeforeOperationHooks(
722
680
  }
723
681
  }
724
682
 
725
- /**
726
- * Execute field-level afterOperation hooks (side effects only)
727
- * Allows fields to perform side effects after database operations
728
- */
729
683
  export async function executeFieldAfterOperationHooks(
730
684
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
731
685
  item: any,
@@ -739,10 +693,8 @@ export async function executeFieldAfterOperationHooks(
739
693
  originalItem?: any,
740
694
  ): Promise<void> {
741
695
  for (const [fieldKey, fieldConfig] of Object.entries(fields)) {
742
- // Skip if no hooks defined
743
696
  if (!fieldConfig.hooks?.afterOperation) continue
744
697
 
745
- // Execute field hook (side effects only, no return value used)
746
698
  if (operation === 'delete') {
747
699
  await fieldConfig.hooks.afterOperation({
748
700
  listKey,
@@ -762,7 +714,6 @@ export async function executeFieldAfterOperationHooks(
762
714
  context,
763
715
  } as Parameters<typeof fieldConfig.hooks.afterOperation>[0])
764
716
  } else {
765
- // operation === 'update'
766
717
  await fieldConfig.hooks.afterOperation({
767
718
  listKey,
768
719
  fieldKey,
@@ -777,10 +728,6 @@ export async function executeFieldAfterOperationHooks(
777
728
  }
778
729
  }
779
730
 
780
- /**
781
- * Validate field-level validation rules using Zod
782
- * Checks isRequired, length constraints, etc.
783
- */
784
731
  export function validateFieldRules(
785
732
  data: Record<string, unknown>,
786
733
  fieldConfigs: Record<string, FieldConfig>,
@@ -792,7 +739,6 @@ export function validateFieldRules(
792
739
  return { errors: [], fieldErrors: {} }
793
740
  }
794
741
 
795
- // Convert field errors to array of error messages
796
742
  const errors = Object.entries(result.errors).map(([_field, message]) => message)
797
743
 
798
744
  return { errors, fieldErrors: result.errors }