@opensaas/stack-core 0.20.1 → 0.22.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 (136) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +334 -0
  3. package/CLAUDE.md +29 -11
  4. package/dist/access/access-filter.d.ts +29 -0
  5. package/dist/access/access-filter.d.ts.map +1 -0
  6. package/dist/access/access-filter.js +68 -0
  7. package/dist/access/access-filter.js.map +1 -0
  8. package/dist/access/engine.d.ts +15 -48
  9. package/dist/access/engine.d.ts.map +1 -1
  10. package/dist/access/engine.js +14 -280
  11. package/dist/access/engine.js.map +1 -1
  12. package/dist/access/field-access.d.ts +44 -0
  13. package/dist/access/field-access.d.ts.map +1 -0
  14. package/dist/access/field-access.js +123 -0
  15. package/dist/access/field-access.js.map +1 -0
  16. package/dist/access/field-access.test.d.ts +2 -0
  17. package/dist/access/field-access.test.d.ts.map +1 -0
  18. package/dist/access/{engine.test.js → field-access.test.js} +2 -2
  19. package/dist/access/field-access.test.js.map +1 -0
  20. package/dist/access/field-visibility.d.ts +13 -0
  21. package/dist/access/field-visibility.d.ts.map +1 -0
  22. package/dist/access/field-visibility.js +178 -0
  23. package/dist/access/field-visibility.js.map +1 -0
  24. package/dist/access/index.d.ts +4 -1
  25. package/dist/access/index.d.ts.map +1 -1
  26. package/dist/access/index.js +8 -1
  27. package/dist/access/index.js.map +1 -1
  28. package/dist/access/multi-column-read-write.test.d.ts +2 -0
  29. package/dist/access/multi-column-read-write.test.d.ts.map +1 -0
  30. package/dist/access/multi-column-read-write.test.js +149 -0
  31. package/dist/access/multi-column-read-write.test.js.map +1 -0
  32. package/dist/config/index.d.ts +1 -1
  33. package/dist/config/index.d.ts.map +1 -1
  34. package/dist/config/types.d.ts +334 -5
  35. package/dist/config/types.d.ts.map +1 -1
  36. package/dist/context/hook-pipeline.d.ts +49 -0
  37. package/dist/context/hook-pipeline.d.ts.map +1 -0
  38. package/dist/context/hook-pipeline.js +75 -0
  39. package/dist/context/hook-pipeline.js.map +1 -0
  40. package/dist/context/index.d.ts.map +1 -1
  41. package/dist/context/index.js +30 -462
  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 +72 -68
  45. package/dist/context/nested-operations.js.map +1 -1
  46. package/dist/context/write-pipeline.d.ts +158 -0
  47. package/dist/context/write-pipeline.d.ts.map +1 -0
  48. package/dist/context/write-pipeline.js +306 -0
  49. package/dist/context/write-pipeline.js.map +1 -0
  50. package/dist/extend.d.ts +3 -0
  51. package/dist/extend.d.ts.map +1 -0
  52. package/dist/extend.js +10 -0
  53. package/dist/extend.js.map +1 -0
  54. package/dist/fields/format-prisma-default.d.ts +35 -0
  55. package/dist/fields/format-prisma-default.d.ts.map +1 -0
  56. package/dist/fields/format-prisma-default.js +52 -0
  57. package/dist/fields/format-prisma-default.js.map +1 -0
  58. package/dist/fields/format-prisma-default.test.d.ts +2 -0
  59. package/dist/fields/format-prisma-default.test.d.ts.map +1 -0
  60. package/dist/fields/format-prisma-default.test.js +54 -0
  61. package/dist/fields/format-prisma-default.test.js.map +1 -0
  62. package/dist/fields/index.d.ts +1 -0
  63. package/dist/fields/index.d.ts.map +1 -1
  64. package/dist/fields/index.js +267 -18
  65. package/dist/fields/index.js.map +1 -1
  66. package/dist/fields/select.test.js +85 -0
  67. package/dist/fields/select.test.js.map +1 -1
  68. package/dist/fields/text-keystone-compat.test.d.ts +2 -0
  69. package/dist/fields/text-keystone-compat.test.d.ts.map +1 -0
  70. package/dist/fields/text-keystone-compat.test.js +93 -0
  71. package/dist/fields/text-keystone-compat.test.js.map +1 -0
  72. package/dist/hooks/index.d.ts +20 -0
  73. package/dist/hooks/index.d.ts.map +1 -1
  74. package/dist/hooks/index.js +246 -0
  75. package/dist/hooks/index.js.map +1 -1
  76. package/dist/index.d.ts +6 -8
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +25 -9
  79. package/dist/index.js.map +1 -1
  80. package/dist/index.test.d.ts +2 -0
  81. package/dist/index.test.d.ts.map +1 -0
  82. package/dist/index.test.js +33 -0
  83. package/dist/index.test.js.map +1 -0
  84. package/dist/internal.d.ts +8 -0
  85. package/dist/internal.d.ts.map +1 -0
  86. package/dist/internal.js +16 -0
  87. package/dist/internal.js.map +1 -0
  88. package/dist/mcp/handler.js +0 -1
  89. package/dist/mcp/handler.js.map +1 -1
  90. package/dist/validation/field-config.d.ts +55 -0
  91. package/dist/validation/field-config.d.ts.map +1 -0
  92. package/dist/validation/field-config.js +100 -0
  93. package/dist/validation/field-config.js.map +1 -0
  94. package/dist/validation/field-config.test.d.ts +2 -0
  95. package/dist/validation/field-config.test.d.ts.map +1 -0
  96. package/dist/validation/field-config.test.js +159 -0
  97. package/dist/validation/field-config.test.js.map +1 -0
  98. package/package.json +11 -3
  99. package/src/access/access-filter.ts +97 -0
  100. package/src/access/engine.ts +13 -396
  101. package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
  102. package/src/access/field-access.ts +159 -0
  103. package/src/access/field-visibility.ts +269 -0
  104. package/src/access/index.ts +7 -4
  105. package/src/access/multi-column-read-write.test.ts +255 -0
  106. package/src/config/index.ts +3 -0
  107. package/src/config/types.ts +342 -4
  108. package/src/context/hook-pipeline.ts +160 -0
  109. package/src/context/index.ts +29 -667
  110. package/src/context/nested-operations.ts +142 -111
  111. package/src/context/write-pipeline.ts +543 -0
  112. package/src/extend.ts +19 -0
  113. package/src/fields/format-prisma-default.test.ts +64 -0
  114. package/src/fields/format-prisma-default.ts +67 -0
  115. package/src/fields/index.ts +375 -20
  116. package/src/fields/select.test.ts +99 -0
  117. package/src/fields/text-keystone-compat.test.ts +126 -0
  118. package/src/hooks/index.ts +270 -0
  119. package/src/index.test.ts +50 -0
  120. package/src/index.ts +35 -82
  121. package/src/internal.ts +49 -0
  122. package/src/mcp/handler.ts +0 -2
  123. package/src/validation/field-config.test.ts +199 -0
  124. package/src/validation/field-config.ts +145 -0
  125. package/tests/access-relationships.test.ts +4 -4
  126. package/tests/access.test.ts +1 -1
  127. package/tests/field-hooks.test.ts +410 -0
  128. package/tests/field-types.test.ts +1 -1
  129. package/tests/hook-pipeline.test.ts +233 -0
  130. package/tests/nested-operation-registry.test.ts +206 -0
  131. package/tests/write-pipeline.test.ts +588 -0
  132. package/tsconfig.tsbuildinfo +1 -1
  133. package/vitest.config.ts +43 -1
  134. package/dist/access/engine.test.d.ts +0 -2
  135. package/dist/access/engine.test.d.ts.map +0 -1
  136. package/dist/access/engine.test.js.map +0 -1
@@ -0,0 +1,199 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { validateFieldConfig, validateConfigFields } from './field-config.js'
3
+ import {
4
+ text,
5
+ integer,
6
+ checkbox,
7
+ timestamp,
8
+ password,
9
+ select,
10
+ json,
11
+ relationship,
12
+ virtual,
13
+ } from '../fields/index.js'
14
+ import type { FieldConfig, OpenSaasConfig } from '../config/types.js'
15
+
16
+ describe('validateFieldConfig', () => {
17
+ describe('well-formed fields pass', () => {
18
+ it.each([
19
+ ['text', text()],
20
+ ['integer', integer()],
21
+ ['checkbox', checkbox()],
22
+ ['timestamp', timestamp()],
23
+ ['password', password()],
24
+ ['select', select({ options: [{ label: 'A', value: 'a' }] })],
25
+ ['json', json()],
26
+ ])('a built-in %s field is self-contained', (_name, field) => {
27
+ expect(validateFieldConfig(field as FieldConfig, 'myField', 'MyList')).toEqual([])
28
+ })
29
+
30
+ it('a relationship field is self-contained via getPrismaRelation', () => {
31
+ const field = relationship({ ref: 'User.posts' })
32
+ expect(validateFieldConfig(field as FieldConfig, 'author', 'Post')).toEqual([])
33
+ })
34
+
35
+ it('a virtual field is self-contained without getPrismaType', () => {
36
+ const field = virtual({
37
+ type: 'string',
38
+ hooks: { resolveOutput: () => 'x' },
39
+ })
40
+ expect(validateFieldConfig(field as FieldConfig, 'fullName', 'User')).toEqual([])
41
+ })
42
+ })
43
+
44
+ describe('missing scalar contract methods fail', () => {
45
+ it('reports a missing getPrismaType naming the list, field, and method', () => {
46
+ const field = text()
47
+ delete field.getPrismaType
48
+
49
+ const errors = validateFieldConfig(field as FieldConfig, 'title', 'Post')
50
+
51
+ expect(errors).toHaveLength(1)
52
+ expect(errors[0]).toMatchObject({
53
+ listKey: 'Post',
54
+ fieldKey: 'title',
55
+ fieldType: 'text',
56
+ missingMethod: 'getPrismaType',
57
+ })
58
+ expect(errors[0].message).toContain('Post.title')
59
+ expect(errors[0].message).toContain('getPrismaType')
60
+ expect(errors[0].message).toContain('not self-contained')
61
+ })
62
+
63
+ it('reports a missing getTypeScriptType naming the method', () => {
64
+ const field = text()
65
+ delete field.getTypeScriptType
66
+
67
+ const errors = validateFieldConfig(field as FieldConfig, 'title', 'Post')
68
+
69
+ expect(errors).toHaveLength(1)
70
+ expect(errors[0].missingMethod).toBe('getTypeScriptType')
71
+ expect(errors[0].message).toContain('getTypeScriptType')
72
+ })
73
+
74
+ it('reports a missing getZodSchema naming the method', () => {
75
+ const field = text()
76
+ delete field.getZodSchema
77
+
78
+ const errors = validateFieldConfig(field as FieldConfig, 'title', 'Post')
79
+
80
+ expect(errors).toHaveLength(1)
81
+ expect(errors[0].missingMethod).toBe('getZodSchema')
82
+ expect(errors[0].message).toContain('getZodSchema')
83
+ })
84
+
85
+ it('reports every missing method when a field implements none', () => {
86
+ const field: FieldConfig = { type: 'custom' }
87
+
88
+ const errors = validateFieldConfig(field, 'mystery', 'Widget')
89
+
90
+ expect(errors.map((e) => e.missingMethod).sort()).toEqual([
91
+ 'getPrismaType',
92
+ 'getTypeScriptType',
93
+ 'getZodSchema',
94
+ ])
95
+ for (const error of errors) {
96
+ expect(error.message).toContain('Widget.mystery')
97
+ expect(error.message).toContain('custom')
98
+ }
99
+ })
100
+
101
+ it('works without a listKey (bare field validation)', () => {
102
+ const field: FieldConfig = { type: 'custom' }
103
+ const errors = validateFieldConfig(field, 'mystery')
104
+ expect(errors).toHaveLength(3)
105
+ expect(errors[0].listKey).toBeUndefined()
106
+ expect(errors[0].message).toContain('Field "mystery"')
107
+ })
108
+ })
109
+
110
+ describe('relationship and virtual variants', () => {
111
+ it('reports a relationship missing getPrismaRelation', () => {
112
+ const field: FieldConfig = { type: 'relationship' }
113
+
114
+ const errors = validateFieldConfig(field, 'author', 'Post')
115
+
116
+ expect(errors).toHaveLength(1)
117
+ expect(errors[0].missingMethod).toBe('getPrismaRelation')
118
+ expect(errors[0].message).toContain('Post.author')
119
+ })
120
+
121
+ it('reports a virtual field missing getTypeScriptType and getZodSchema', () => {
122
+ const field: FieldConfig = { type: 'virtual', virtual: true }
123
+
124
+ const errors = validateFieldConfig(field, 'fullName', 'User')
125
+
126
+ expect(errors.map((e) => e.missingMethod).sort()).toEqual([
127
+ 'getTypeScriptType',
128
+ 'getZodSchema',
129
+ ])
130
+ })
131
+
132
+ it('does not require getPrismaType for virtual fields', () => {
133
+ const field: FieldConfig = { type: 'virtual', virtual: true }
134
+ const errors = validateFieldConfig(field, 'fullName', 'User')
135
+ expect(errors.some((e) => e.missingMethod === 'getPrismaType')).toBe(false)
136
+ })
137
+ })
138
+ })
139
+
140
+ describe('validateConfigFields', () => {
141
+ it('returns no errors for a fully compliant config', () => {
142
+ const config: OpenSaasConfig = {
143
+ db: {
144
+ provider: 'sqlite',
145
+ prismaClientConstructor: () => null as never,
146
+ },
147
+ lists: {
148
+ User: {
149
+ fields: {
150
+ name: text({ validation: { isRequired: true } }),
151
+ posts: relationship({ ref: 'Post.author', many: true }),
152
+ },
153
+ },
154
+ Post: {
155
+ fields: {
156
+ title: text(),
157
+ author: relationship({ ref: 'User.posts' }),
158
+ },
159
+ },
160
+ },
161
+ }
162
+
163
+ expect(validateConfigFields(config)).toEqual([])
164
+ })
165
+
166
+ it('collects per-field errors across every list and names each location', () => {
167
+ const brokenTitle = text()
168
+ delete brokenTitle.getPrismaType
169
+
170
+ const brokenName = text()
171
+ delete brokenName.getZodSchema
172
+
173
+ const config: OpenSaasConfig = {
174
+ db: {
175
+ provider: 'sqlite',
176
+ prismaClientConstructor: () => null as never,
177
+ },
178
+ lists: {
179
+ User: {
180
+ fields: {
181
+ name: brokenName,
182
+ },
183
+ },
184
+ Post: {
185
+ fields: {
186
+ title: brokenTitle,
187
+ },
188
+ },
189
+ },
190
+ }
191
+
192
+ const errors = validateConfigFields(config)
193
+
194
+ expect(errors).toHaveLength(2)
195
+ const byField = Object.fromEntries(errors.map((e) => [`${e.listKey}.${e.fieldKey}`, e]))
196
+ expect(byField['User.name'].missingMethod).toBe('getZodSchema')
197
+ expect(byField['Post.title'].missingMethod).toBe('getPrismaType')
198
+ })
199
+ })
@@ -0,0 +1,145 @@
1
+ import type { FieldConfig, OpenSaasConfig } from '../config/types.js'
2
+
3
+ /**
4
+ * A single self-containment violation found on a field.
5
+ *
6
+ * Field builders advertise a self-containment contract: every field provides
7
+ * the generation hooks the schema/type generators delegate to. When a field
8
+ * (often a third-party one) fails to implement a required method, the
9
+ * generators historically threw deep inside generation with an opaque stack
10
+ * trace. This structured error lets the contract be checked up front and
11
+ * reported per-field with enough context to act on.
12
+ */
13
+ export interface FieldConfigValidationError {
14
+ /** The list the offending field belongs to (`undefined` when validating a bare field). */
15
+ listKey?: string
16
+ /** The field key (property name) within the list. */
17
+ fieldKey: string
18
+ /** The field's declared `type` discriminator (e.g. `'text'`, `'virtual'`). */
19
+ fieldType: string
20
+ /** The contract method that is missing. */
21
+ missingMethod: 'getPrismaType' | 'getTypeScriptType' | 'getZodSchema' | 'getPrismaRelation'
22
+ /** A human-readable, ready-to-print message naming the list, field, and method. */
23
+ message: string
24
+ }
25
+
26
+ /**
27
+ * Describe a field for an error message. Falls back to a literal when a
28
+ * `type`-less value slips through (e.g. a malformed third-party field).
29
+ */
30
+ function describeFieldType(field: FieldConfig): string {
31
+ return typeof field.type === 'string' && field.type.length > 0 ? field.type : 'unknown'
32
+ }
33
+
34
+ /**
35
+ * Type-safe probe for a function-valued property on a field config.
36
+ *
37
+ * The three scalar contract methods live on `BaseFieldConfig` and are checked
38
+ * directly. `getPrismaRelation` only exists on the relationship field variant,
39
+ * so it is probed structurally here without widening the public type or
40
+ * reaching for a cast.
41
+ */
42
+ function hasFieldMethod(field: FieldConfig, method: string): boolean {
43
+ const value: unknown = Reflect.get(field, method)
44
+ return typeof value === 'function'
45
+ }
46
+
47
+ /**
48
+ * Build the canonical error message for a missing contract method.
49
+ */
50
+ function buildMessage(
51
+ fieldType: string,
52
+ method: FieldConfigValidationError['missingMethod'],
53
+ fieldKey: string,
54
+ listKey?: string,
55
+ ): string {
56
+ const location = listKey ? `Field "${listKey}.${fieldKey}"` : `Field "${fieldKey}"`
57
+ return (
58
+ `${location} (type "${fieldType}") is not self-contained: it does not implement ` +
59
+ `${method}(). Field builders must provide this method so the generator can ` +
60
+ `produce schema and types without inspecting field internals.`
61
+ )
62
+ }
63
+
64
+ /**
65
+ * Validate a single field against the self-containment contract.
66
+ *
67
+ * The contract is conditional on field kind, mirroring exactly where the
68
+ * generators delegate:
69
+ *
70
+ * - `relationship` fields contribute schema via `getPrismaRelation` and are
71
+ * skipped by the scalar Prisma/TypeScript/Zod paths, so only
72
+ * `getPrismaRelation` is required.
73
+ * - `virtual` fields are not stored in the database, so `getPrismaType` is
74
+ * legitimately absent; they must still provide `getTypeScriptType` and
75
+ * `getZodSchema`.
76
+ * - every other (stored scalar) field must provide `getPrismaType`,
77
+ * `getTypeScriptType`, and `getZodSchema`.
78
+ *
79
+ * @param field - The field config produced by a field builder.
80
+ * @param fieldKey - The field's key within its list (for messages).
81
+ * @param listKey - The owning list's key (optional, for messages).
82
+ * @returns Zero or more structured errors; empty means the field is compliant.
83
+ */
84
+ export function validateFieldConfig(
85
+ field: FieldConfig,
86
+ fieldKey: string,
87
+ listKey?: string,
88
+ ): FieldConfigValidationError[] {
89
+ const errors: FieldConfigValidationError[] = []
90
+ const fieldType = describeFieldType(field)
91
+
92
+ const requireMethod = (method: FieldConfigValidationError['missingMethod']): void => {
93
+ if (!hasFieldMethod(field, method)) {
94
+ errors.push({
95
+ listKey,
96
+ fieldKey,
97
+ fieldType,
98
+ missingMethod: method,
99
+ message: buildMessage(fieldType, method, fieldKey, listKey),
100
+ })
101
+ }
102
+ }
103
+
104
+ if (field.type === 'relationship') {
105
+ // Relationships render through the relationship path only.
106
+ requireMethod('getPrismaRelation')
107
+ return errors
108
+ }
109
+
110
+ if (field.virtual === true || field.type === 'virtual') {
111
+ // Virtual fields are not persisted, so getPrismaType is intentionally absent.
112
+ requireMethod('getTypeScriptType')
113
+ requireMethod('getZodSchema')
114
+ return errors
115
+ }
116
+
117
+ // Stored scalar fields must implement the full generation contract.
118
+ requireMethod('getPrismaType')
119
+ requireMethod('getTypeScriptType')
120
+ requireMethod('getZodSchema')
121
+
122
+ return errors
123
+ }
124
+
125
+ /**
126
+ * Validate every field across every list in a config.
127
+ *
128
+ * Intended to run once, before any generation, so a misimplemented field
129
+ * surfaces a clear per-field message instead of a deep generator stack trace.
130
+ *
131
+ * @param config - The fully resolved OpenSaas config.
132
+ * @returns All self-containment violations, flattened across lists and fields.
133
+ */
134
+ export function validateConfigFields(config: OpenSaasConfig): FieldConfigValidationError[] {
135
+ const errors: FieldConfigValidationError[] = []
136
+
137
+ for (const [listKey, listConfig] of Object.entries(config.lists)) {
138
+ if (!listConfig?.fields) continue
139
+ for (const [fieldKey, fieldConfig] of Object.entries(listConfig.fields)) {
140
+ errors.push(...validateFieldConfig(fieldConfig, fieldKey, listKey))
141
+ }
142
+ }
143
+
144
+ return errors
145
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { filterReadableFields, getRelatedListConfig } from '../src/access/engine.js'
2
+ import { filterReadableFields, getRelatedListConfig } from '../src/access/index.js'
3
3
  import type { OpenSaasConfig, AccessContext } from '../src/index.js'
4
4
 
5
5
  describe('Relationship Access Control', () => {
@@ -153,7 +153,7 @@ describe('Relationship Access Control', () => {
153
153
  }
154
154
 
155
155
  // Test that buildIncludeWithAccessControl excludes the denied relationship
156
- const { buildIncludeWithAccessControl } = await import('../src/access/engine.js')
156
+ const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
157
157
 
158
158
  const include = await buildIncludeWithAccessControl(
159
159
  config.lists.Post.fields,
@@ -319,7 +319,7 @@ describe('Relationship Access Control', () => {
319
319
  }
320
320
 
321
321
  // Test that buildIncludeWithAccessControl creates the right where clause
322
- const { buildIncludeWithAccessControl } = await import('../src/access/engine.js')
322
+ const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
323
323
 
324
324
  const include = await buildIncludeWithAccessControl(
325
325
  config.lists.User.fields,
@@ -486,7 +486,7 @@ describe('Relationship Access Control', () => {
486
486
  }
487
487
 
488
488
  // Test that buildIncludeWithAccessControl creates session-based where clause
489
- const { buildIncludeWithAccessControl } = await import('../src/access/engine.js')
489
+ const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
490
490
 
491
491
  const include = await buildIncludeWithAccessControl(
492
492
  config.lists.User.fields,
@@ -7,7 +7,7 @@ import {
7
7
  filterWritableFields,
8
8
  isBoolean,
9
9
  isPrismaFilter,
10
- } from '../src/access/engine.js'
10
+ } from '../src/access/index.js'
11
11
  import type { AccessControl, FieldAccess, AccessContext } from '../src/access/types.js'
12
12
 
13
13
  describe('Access Control', () => {