@opensaas/stack-core 0.26.0 → 0.27.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 (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +54 -12
  3. package/dist/access/access-filter.d.ts +18 -1
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +62 -5
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.d.ts +2 -0
  8. package/dist/access/access-filter.test.d.ts.map +1 -0
  9. package/dist/access/access-filter.test.js +116 -0
  10. package/dist/access/access-filter.test.js.map +1 -0
  11. package/dist/access/field-visibility.js.map +1 -1
  12. package/dist/access/index.d.ts +1 -1
  13. package/dist/access/index.d.ts.map +1 -1
  14. package/dist/access/index.js +1 -1
  15. package/dist/access/index.js.map +1 -1
  16. package/dist/access/multi-column-read-write.test.js.map +1 -1
  17. package/dist/config/label.d.ts +22 -0
  18. package/dist/config/label.d.ts.map +1 -0
  19. package/dist/config/label.js +48 -0
  20. package/dist/config/label.js.map +1 -0
  21. package/dist/config/plugin-engine.js.map +1 -1
  22. package/dist/config/types.d.ts +19 -5
  23. package/dist/config/types.d.ts.map +1 -1
  24. package/dist/context/index.d.ts +7 -0
  25. package/dist/context/index.d.ts.map +1 -1
  26. package/dist/context/index.js +42 -4
  27. package/dist/context/index.js.map +1 -1
  28. package/dist/context/nested-operations.js.map +1 -1
  29. package/dist/context/transaction-boundary.d.ts.map +1 -1
  30. package/dist/context/write-pipeline.d.ts.map +1 -1
  31. package/dist/context/write-pipeline.js.map +1 -1
  32. package/dist/fields/calendar-day.test.js +133 -7
  33. package/dist/fields/calendar-day.test.js.map +1 -1
  34. package/dist/fields/format-prisma-default.js.map +1 -1
  35. package/dist/fields/index.d.ts +7 -3
  36. package/dist/fields/index.d.ts.map +1 -1
  37. package/dist/fields/index.js +37 -8
  38. package/dist/fields/index.js.map +1 -1
  39. package/dist/hooks/index.d.ts.map +1 -1
  40. package/dist/hooks/index.js.map +1 -1
  41. package/dist/index.d.ts +3 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +8 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/query/index.d.ts.map +1 -1
  46. package/dist/query/relationship-options.d.ts +26 -0
  47. package/dist/query/relationship-options.d.ts.map +1 -0
  48. package/dist/query/relationship-options.js +43 -0
  49. package/dist/query/relationship-options.js.map +1 -0
  50. package/dist/query/relationship-options.test.d.ts +2 -0
  51. package/dist/query/relationship-options.test.d.ts.map +1 -0
  52. package/dist/query/relationship-options.test.js +121 -0
  53. package/dist/query/relationship-options.test.js.map +1 -0
  54. package/dist/utils/password.d.ts.map +1 -1
  55. package/dist/utils/password.js.map +1 -1
  56. package/dist/validation/schema.js.map +1 -1
  57. package/package.json +6 -5
  58. package/src/access/access-filter.test.ts +149 -0
  59. package/src/access/access-filter.ts +82 -9
  60. package/src/access/index.ts +5 -1
  61. package/src/config/label.ts +58 -0
  62. package/src/config/types.ts +20 -13
  63. package/src/context/index.ts +57 -5
  64. package/src/context/write-pipeline.ts +1 -2
  65. package/src/fields/calendar-day.test.ts +158 -7
  66. package/src/fields/index.ts +39 -11
  67. package/src/hooks/index.ts +1 -2
  68. package/src/index.ts +11 -0
  69. package/src/query/relationship-options.test.ts +157 -0
  70. package/src/query/relationship-options.ts +70 -0
  71. package/tests/context.test.ts +220 -0
  72. package/tests/label.test.ts +135 -0
  73. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,70 @@
1
+ import type { OpenSaasConfig } from '../config/types.js'
2
+ import { getLabelFieldName, getItemLabel } from '../config/label.js'
3
+ import { defineFragment, runQuery, type QueryRunnerContext } from './index.js'
4
+
5
+ const DEFAULT_TAKE = 50
6
+
7
+ export interface RelationshipOption {
8
+ id: string
9
+ label: string
10
+ }
11
+
12
+ export interface RelationshipOptionsArgs {
13
+ /** Filters the label field via `contains` when it is a text field. */
14
+ search?: string
15
+ /** Bounds the primary (search-scoped) window. @default 50 */
16
+ take?: number
17
+ /** Always unioned into the result, even outside the search/take window. */
18
+ selectedIds?: string[]
19
+ }
20
+
21
+ /**
22
+ * Bounded, projected fetch of `{ id, label }` options for a relationship
23
+ * editor — the read primitive behind the `relationshipOptions` serverAction
24
+ * op. Selects only `id` and the resolved label field (via
25
+ * {@link getLabelFieldName}), so the fragment carries no relation keys and
26
+ * `buildIncludeWithAccessControl`'s depth-5 auto-include never runs.
27
+ *
28
+ * Operation-level `query` access on `relatedListKey` still applies — a denied
29
+ * list resolves to `[]` (via the underlying access-controlled `findMany`).
30
+ */
31
+ export async function getRelationshipOptions(
32
+ context: QueryRunnerContext,
33
+ config: OpenSaasConfig,
34
+ relatedListKey: string,
35
+ args: RelationshipOptionsArgs = {},
36
+ ): Promise<RelationshipOption[]> {
37
+ const relatedListConfig = config.lists[relatedListKey]
38
+ if (!relatedListConfig) return []
39
+
40
+ const labelField = getLabelFieldName(relatedListConfig)
41
+ const fragment = defineFragment<Record<string, unknown>>()({
42
+ id: true,
43
+ [labelField]: true,
44
+ })
45
+
46
+ const { search, take = DEFAULT_TAKE, selectedIds = [] } = args
47
+ const labelFieldConfig = relatedListConfig.fields[labelField] as { type?: string } | undefined
48
+ const where =
49
+ search && labelFieldConfig?.type === 'text' ? { [labelField]: { contains: search } } : undefined
50
+
51
+ const primary = await runQuery(context, relatedListKey, fragment, {
52
+ where,
53
+ orderBy: { [labelField]: 'asc' },
54
+ take,
55
+ })
56
+
57
+ const seenIds = new Set(primary.map((item) => (item as { id: string }).id))
58
+ const missingSelectedIds = selectedIds.filter((id) => !seenIds.has(id))
59
+
60
+ const selected = missingSelectedIds.length
61
+ ? await runQuery(context, relatedListKey, fragment, {
62
+ where: { id: { in: missingSelectedIds } },
63
+ })
64
+ : []
65
+
66
+ return [...primary, ...selected].map((item) => ({
67
+ id: (item as { id: string }).id,
68
+ label: getItemLabel(relatedListConfig, item as Record<string, unknown>),
69
+ }))
70
+ }
@@ -57,6 +57,7 @@ describe('getContext', () => {
57
57
  fields: {
58
58
  title: { type: 'text' },
59
59
  content: { type: 'text' },
60
+ author: { type: 'relationship', ref: 'User.posts' },
60
61
  },
61
62
  access: {
62
63
  operation: {
@@ -200,6 +201,119 @@ describe('getContext', () => {
200
201
  error: 'Database connection failed',
201
202
  })
202
203
  })
204
+
205
+ describe('relationshipOptions', () => {
206
+ it('returns { id, label }[] for the related list, unfiltered/unincluded', async () => {
207
+ mockPrisma.user.findMany.mockResolvedValue([
208
+ { id: 'u1', name: 'Ada' },
209
+ { id: 'u2', name: 'Alan' },
210
+ ])
211
+
212
+ const context = await getContext(config, mockPrisma, null)
213
+ const result = await context.serverAction({
214
+ listKey: 'Post',
215
+ action: 'relationshipOptions',
216
+ field: 'author',
217
+ })
218
+
219
+ expect(result).toEqual({
220
+ success: true,
221
+ data: [
222
+ { id: 'u1', label: 'Ada' },
223
+ { id: 'u2', label: 'Alan' },
224
+ ],
225
+ })
226
+ const call = mockPrisma.user.findMany.mock.calls[0][0]
227
+ expect(call.include).toBeUndefined()
228
+ })
229
+
230
+ it('bounds the query by take', async () => {
231
+ mockPrisma.user.findMany.mockResolvedValue([{ id: 'u1', name: 'Ada' }])
232
+
233
+ const context = await getContext(config, mockPrisma, null)
234
+ await context.serverAction({
235
+ listKey: 'Post',
236
+ action: 'relationshipOptions',
237
+ field: 'author',
238
+ take: 1,
239
+ })
240
+
241
+ expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 1 }))
242
+ })
243
+
244
+ it('unions selectedIds beyond the take/search window', async () => {
245
+ mockPrisma.user.findMany.mockResolvedValueOnce([{ id: 'u1', name: 'Ada' }])
246
+ mockPrisma.user.findMany.mockResolvedValueOnce([{ id: 'u9', name: 'Zed' }])
247
+
248
+ const context = await getContext(config, mockPrisma, null)
249
+ const result = await context.serverAction({
250
+ listKey: 'Post',
251
+ action: 'relationshipOptions',
252
+ field: 'author',
253
+ take: 1,
254
+ selectedIds: ['u9'],
255
+ })
256
+
257
+ expect(result).toEqual({
258
+ success: true,
259
+ data: [
260
+ { id: 'u1', label: 'Ada' },
261
+ { id: 'u9', label: 'Zed' },
262
+ ],
263
+ })
264
+ })
265
+
266
+ it('returns [] data when the related list query access is denied', async () => {
267
+ const deniedConfig: OpenSaasConfig = {
268
+ ...config,
269
+ lists: {
270
+ ...config.lists,
271
+ User: {
272
+ ...config.lists.User,
273
+ access: { operation: { query: () => false } },
274
+ },
275
+ },
276
+ }
277
+ mockPrisma.user.findMany.mockResolvedValue([{ id: 'u1', name: 'Ada' }])
278
+
279
+ const context = await getContext(deniedConfig, mockPrisma, null)
280
+ const result = await context.serverAction({
281
+ listKey: 'Post',
282
+ action: 'relationshipOptions',
283
+ field: 'author',
284
+ })
285
+
286
+ expect(result).toEqual({ success: true, data: [] })
287
+ })
288
+
289
+ it('returns an error when the field is not a relationship field', async () => {
290
+ const context = await getContext(config, mockPrisma, null)
291
+ const result = await context.serverAction({
292
+ listKey: 'Post',
293
+ action: 'relationshipOptions',
294
+ field: 'title',
295
+ })
296
+
297
+ expect(result).toEqual({
298
+ success: false,
299
+ error: 'Field "title" on list "Post" is not a relationship field',
300
+ })
301
+ })
302
+
303
+ it('returns an error when the field does not exist', async () => {
304
+ const context = await getContext(config, mockPrisma, null)
305
+ const result = await context.serverAction({
306
+ listKey: 'Post',
307
+ action: 'relationshipOptions',
308
+ field: 'nonexistent',
309
+ })
310
+
311
+ expect(result).toEqual({
312
+ success: false,
313
+ error: 'Field "nonexistent" on list "Post" is not a relationship field',
314
+ })
315
+ })
316
+ })
203
317
  })
204
318
 
205
319
  describe('db operations', () => {
@@ -705,6 +819,112 @@ describe('getContext', () => {
705
819
  // identical `accessControlledInclude === undefined` branch verified above,
706
820
  // so the resolveOutput probe covers all three non-denial cases.
707
821
  })
822
+
823
+ // Regression for #628: a virtual field named in `include` used to be
824
+ // forwarded straight through to Prisma, which throws "Unknown field"
825
+ // because virtual fields have no database column. The runtime must
826
+ // strip virtual keys from the include payload while still computing
827
+ // the virtual value (via resolveOutput) and leaving real relationship
828
+ // includes — access-controlled or not — untouched.
829
+ describe('virtual fields named in include no longer reach Prisma (#628)', () => {
830
+ function configWithVirtualDisplayName(): OpenSaasConfig {
831
+ return {
832
+ ...relConfig,
833
+ lists: {
834
+ ...relConfig.lists,
835
+ Author: {
836
+ ...relConfig.lists.Author,
837
+ fields: {
838
+ ...relConfig.lists.Author.fields,
839
+ displayName: virtual({
840
+ type: 'string',
841
+ hooks: {
842
+ resolveOutput: ({ item }) => `Author: ${item.name}`,
843
+ },
844
+ }),
845
+ },
846
+ },
847
+ },
848
+ }
849
+ }
850
+
851
+ it('findMany: strips the virtual key from the Prisma include but keeps the real relationship include, and computes the virtual value', async () => {
852
+ const vConfig = configWithVirtualDisplayName()
853
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
854
+
855
+ const context = await getContext(vConfig, relPrisma, null)
856
+ const result = await context.db.author.findMany({
857
+ include: { displayName: true, posts: true },
858
+ })
859
+
860
+ const call = relPrisma.author.findMany.mock.calls[0][0]
861
+ expect(call.include).not.toHaveProperty('displayName')
862
+ expect(call.include.posts).toBeDefined()
863
+ expect(result[0].displayName).toBe('Author: Jo')
864
+ })
865
+
866
+ it('findUnique: strips the virtual key from the Prisma include but keeps the real relationship include, and computes the virtual value', async () => {
867
+ const vConfig = configWithVirtualDisplayName()
868
+ relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo', posts: [] })
869
+
870
+ const context = await getContext(vConfig, relPrisma, null)
871
+ const result = await context.db.author.findUnique({
872
+ where: { id: 'a1' },
873
+ include: { displayName: true, posts: true },
874
+ })
875
+
876
+ const call = relPrisma.author.findFirst.mock.calls[0][0]
877
+ expect(call.include).not.toHaveProperty('displayName')
878
+ expect(call.include.posts).toBeDefined()
879
+ expect(result.displayName).toBe('Author: Jo')
880
+ })
881
+
882
+ it('the virtual value is populated even when omitted from include', async () => {
883
+ const vConfig = configWithVirtualDisplayName()
884
+ relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo' })
885
+
886
+ const context = await getContext(vConfig, relPrisma, null)
887
+ const result = await context.db.author.findUnique({ where: { id: 'a1' } })
888
+
889
+ expect(result.displayName).toBe('Author: Jo')
890
+ })
891
+
892
+ it('sudo: the virtual key is stripped from the Prisma include even though it bypasses access control', async () => {
893
+ const vConfig = configWithVirtualDisplayName()
894
+ relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
895
+
896
+ const context = await getContext(vConfig, relPrisma, null).sudo()
897
+ const result = await context.db.author.findMany({
898
+ include: { displayName: true, posts: true },
899
+ })
900
+
901
+ const call = relPrisma.author.findMany.mock.calls[0][0]
902
+ expect(call.include).not.toHaveProperty('displayName')
903
+ // The real relationship is unaffected — sudo still passes it through as-is.
904
+ expect(call.include.posts).toBe(true)
905
+ expect(result[0].displayName).toBe('Author: Jo')
906
+ })
907
+
908
+ it('read access control on the virtual field is still enforced (denied field is omitted)', async () => {
909
+ const vConfig = configWithVirtualDisplayName()
910
+ vConfig.lists.Author.fields.displayName = virtual({
911
+ type: 'string',
912
+ access: { read: () => false },
913
+ hooks: {
914
+ resolveOutput: ({ item }) => `Author: ${item.name}`,
915
+ },
916
+ })
917
+ relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo' })
918
+
919
+ const context = await getContext(vConfig, relPrisma, null)
920
+ const result = await context.db.author.findUnique({
921
+ where: { id: 'a1' },
922
+ include: { displayName: true },
923
+ })
924
+
925
+ expect(result).not.toHaveProperty('displayName')
926
+ })
927
+ })
708
928
  })
709
929
 
710
930
  it('should delegate create to prisma with access control and hooks', async () => {
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { getLabelFieldName, getItemLabel } from '../src/config/label.js'
3
+ import type { ListConfig, TypeInfo } from '../src/config/types.js'
4
+
5
+ function makeListConfig(
6
+ fields: ListConfig<TypeInfo>['fields'],
7
+ labelField?: string,
8
+ ): ListConfig<TypeInfo> {
9
+ return {
10
+ fields,
11
+ ui: labelField !== undefined ? { labelField } : undefined,
12
+ }
13
+ }
14
+
15
+ describe('label seam', () => {
16
+ describe('getLabelFieldName', () => {
17
+ it('uses the configured ui.labelField when it references a scalar field', () => {
18
+ const listConfig = makeListConfig(
19
+ { name: { type: 'text' }, email: { type: 'text' } },
20
+ 'email',
21
+ )
22
+ expect(getLabelFieldName(listConfig)).toBe('email')
23
+ })
24
+
25
+ it('falls back to "name" when no ui.labelField is configured', () => {
26
+ const listConfig = makeListConfig({ name: { type: 'text' }, title: { type: 'text' } })
27
+ expect(getLabelFieldName(listConfig)).toBe('name')
28
+ })
29
+
30
+ it('falls back to "title" when "name" is not declared', () => {
31
+ const listConfig = makeListConfig({ title: { type: 'text' }, body: { type: 'text' } })
32
+ expect(getLabelFieldName(listConfig)).toBe('title')
33
+ })
34
+
35
+ it('falls back to "id" when neither "name" nor "title" is declared', () => {
36
+ const listConfig = makeListConfig({ body: { type: 'text' } })
37
+ expect(getLabelFieldName(listConfig)).toBe('id')
38
+ })
39
+
40
+ it('throws when ui.labelField references a field not declared on the list', () => {
41
+ const listConfig = makeListConfig({ name: { type: 'text' } }, 'nickname')
42
+ expect(() => getLabelFieldName(listConfig)).toThrow(
43
+ /ui.labelField "nickname" does not reference a field declared on this list/,
44
+ )
45
+ })
46
+
47
+ it('throws when ui.labelField references a relationship field', () => {
48
+ const listConfig = makeListConfig(
49
+ { name: { type: 'text' }, author: { type: 'relationship' } },
50
+ 'author',
51
+ )
52
+ expect(() => getLabelFieldName(listConfig)).toThrow(
53
+ /ui.labelField "author" must reference a scalar field, not a relationship/,
54
+ )
55
+ })
56
+ })
57
+
58
+ describe('getItemLabel', () => {
59
+ it('reads the resolved label field off the row', () => {
60
+ const listConfig = makeListConfig({ name: { type: 'text' } })
61
+ expect(getItemLabel(listConfig, { id: '1', name: 'Ada Lovelace' })).toBe('Ada Lovelace')
62
+ })
63
+
64
+ it('falls back to id when the resolved field is missing from the row', () => {
65
+ const listConfig = makeListConfig({ name: { type: 'text' } })
66
+ // e.g. stripped by field-level access
67
+ expect(getItemLabel(listConfig, { id: 'row-1' })).toBe('row-1')
68
+ })
69
+
70
+ it('falls back to id when the resolved field is null on the row', () => {
71
+ const listConfig = makeListConfig({ name: { type: 'text' } })
72
+ expect(getItemLabel(listConfig, { id: 'row-2', name: null })).toBe('row-2')
73
+ })
74
+
75
+ it('respects a configured ui.labelField', () => {
76
+ const listConfig = makeListConfig(
77
+ { name: { type: 'text' }, email: { type: 'text' } },
78
+ 'email',
79
+ )
80
+ expect(getItemLabel(listConfig, { id: '1', name: 'Ada', email: 'ada@example.com' })).toBe(
81
+ 'ada@example.com',
82
+ )
83
+ })
84
+ })
85
+
86
+ describe('projection⇄render agreement', () => {
87
+ const cases: Array<{
88
+ description: string
89
+ labelField?: string
90
+ fields: ListConfig<TypeInfo>['fields']
91
+ row: Record<string, unknown>
92
+ }> = [
93
+ {
94
+ description: 'configured labelField, field present on row',
95
+ labelField: 'email',
96
+ fields: { name: { type: 'text' }, email: { type: 'text' } },
97
+ row: { id: '1', name: 'Ada', email: 'ada@example.com' },
98
+ },
99
+ {
100
+ description: 'no labelField, name present',
101
+ fields: { name: { type: 'text' }, title: { type: 'text' } },
102
+ row: { id: '2', name: 'Alan', title: 'Dr' },
103
+ },
104
+ {
105
+ description: 'no labelField, only title present',
106
+ fields: { title: { type: 'text' } },
107
+ row: { id: '3', title: 'Untitled Post' },
108
+ },
109
+ {
110
+ description: 'no labelField, no name/title, falls back to id',
111
+ fields: { body: { type: 'text' } },
112
+ row: { id: '4', body: 'hello' },
113
+ },
114
+ {
115
+ description: 'configured labelField, field stripped from row by access control',
116
+ labelField: 'email',
117
+ fields: { name: { type: 'text' }, email: { type: 'text' } },
118
+ row: { id: '5', name: 'Grace' },
119
+ },
120
+ ]
121
+
122
+ it.each(cases)(
123
+ 'the field getLabelFieldName resolves is the field getItemLabel reads: $description',
124
+ ({ labelField, fields, row }) => {
125
+ const listConfig = makeListConfig(fields, labelField)
126
+ const resolvedFieldName = getLabelFieldName(listConfig)
127
+ const expectedLabel = Object.prototype.hasOwnProperty.call(row, resolvedFieldName)
128
+ ? (row[resolvedFieldName] ?? row.id)
129
+ : row.id
130
+
131
+ expect(getItemLabel(listConfig, row)).toBe(String(expectedLabel))
132
+ },
133
+ )
134
+ })
135
+ })