@opensaas/stack-core 0.21.0 → 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 (63) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +262 -0
  3. package/CLAUDE.md +11 -9
  4. package/dist/access/field-visibility.d.ts.map +1 -1
  5. package/dist/access/field-visibility.js +29 -6
  6. package/dist/access/field-visibility.js.map +1 -1
  7. package/dist/access/multi-column-read-write.test.d.ts +2 -0
  8. package/dist/access/multi-column-read-write.test.d.ts.map +1 -0
  9. package/dist/access/multi-column-read-write.test.js +149 -0
  10. package/dist/access/multi-column-read-write.test.js.map +1 -0
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.d.ts.map +1 -1
  13. package/dist/config/types.d.ts +289 -1
  14. package/dist/config/types.d.ts.map +1 -1
  15. package/dist/extend.d.ts +1 -1
  16. package/dist/extend.d.ts.map +1 -1
  17. package/dist/fields/format-prisma-default.d.ts +35 -0
  18. package/dist/fields/format-prisma-default.d.ts.map +1 -0
  19. package/dist/fields/format-prisma-default.js +52 -0
  20. package/dist/fields/format-prisma-default.js.map +1 -0
  21. package/dist/fields/format-prisma-default.test.d.ts +2 -0
  22. package/dist/fields/format-prisma-default.test.d.ts.map +1 -0
  23. package/dist/fields/format-prisma-default.test.js +54 -0
  24. package/dist/fields/format-prisma-default.test.js.map +1 -0
  25. package/dist/fields/index.d.ts +1 -1
  26. package/dist/fields/index.d.ts.map +1 -1
  27. package/dist/fields/index.js +54 -16
  28. package/dist/fields/index.js.map +1 -1
  29. package/dist/fields/select.test.js +85 -0
  30. package/dist/fields/select.test.js.map +1 -1
  31. package/dist/fields/text-keystone-compat.test.d.ts +2 -0
  32. package/dist/fields/text-keystone-compat.test.d.ts.map +1 -0
  33. package/dist/fields/text-keystone-compat.test.js +93 -0
  34. package/dist/fields/text-keystone-compat.test.js.map +1 -0
  35. package/dist/hooks/index.d.ts.map +1 -1
  36. package/dist/hooks/index.js +60 -16
  37. package/dist/hooks/index.js.map +1 -1
  38. package/dist/index.d.ts +3 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +7 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/index.test.d.ts +2 -0
  43. package/dist/index.test.d.ts.map +1 -0
  44. package/dist/index.test.js +33 -0
  45. package/dist/index.test.js.map +1 -0
  46. package/dist/mcp/handler.js +0 -1
  47. package/dist/mcp/handler.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/access/field-visibility.ts +28 -6
  50. package/src/access/multi-column-read-write.test.ts +255 -0
  51. package/src/config/index.ts +2 -0
  52. package/src/config/types.ts +291 -0
  53. package/src/extend.ts +6 -1
  54. package/src/fields/format-prisma-default.test.ts +64 -0
  55. package/src/fields/format-prisma-default.ts +67 -0
  56. package/src/fields/index.ts +65 -18
  57. package/src/fields/select.test.ts +99 -0
  58. package/src/fields/text-keystone-compat.test.ts +126 -0
  59. package/src/hooks/index.ts +60 -17
  60. package/src/index.test.ts +50 -0
  61. package/src/index.ts +17 -1
  62. package/src/mcp/handler.ts +0 -2
  63. package/tsconfig.tsbuildinfo +1 -1
@@ -52,6 +52,48 @@ describe('select field builder', () => {
52
52
  expect(result.modifiers).toBe(' @default("draft")')
53
53
  })
54
54
 
55
+ it('should emit NOT NULL (no ?) for optional string select with a default', () => {
56
+ const field = select({
57
+ options: [
58
+ { label: 'Draft', value: 'draft' },
59
+ { label: 'Published', value: 'published' },
60
+ ],
61
+ defaultValue: 'draft',
62
+ })
63
+
64
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
65
+ // Default behaviour: a present default makes the column NOT NULL
66
+ expect(result.modifiers).toBe(' @default("draft")')
67
+ expect(result.modifiers).not.toContain('?')
68
+ })
69
+
70
+ it('should force ? with db.isNullable even when a default is present (string)', () => {
71
+ const field = select({
72
+ options: [
73
+ { label: 'Draft', value: 'draft' },
74
+ { label: 'Published', value: 'published' },
75
+ ],
76
+ defaultValue: 'draft',
77
+ db: { isNullable: true },
78
+ })
79
+
80
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
81
+ expect(result.type).toBe('String')
82
+ expect(result.modifiers).toBe('? @default("draft")')
83
+ })
84
+
85
+ it('should keep ? from db.isNullable for a required string select with default', () => {
86
+ const field = select({
87
+ options: [{ label: 'Draft', value: 'draft' }],
88
+ defaultValue: 'draft',
89
+ validation: { isRequired: true },
90
+ db: { isNullable: true },
91
+ })
92
+
93
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
94
+ expect(result.modifiers).toBe('? @default("draft")')
95
+ })
96
+
55
97
  it('should generate union TypeScript type from options', () => {
56
98
  const field = select({
57
99
  options: [
@@ -205,6 +247,63 @@ describe('select field builder', () => {
205
247
  expect(result.modifiers).not.toContain('"')
206
248
  })
207
249
 
250
+ it('should emit NOT NULL (no ?) for optional enum select with a default', () => {
251
+ const field = select({
252
+ options: [
253
+ { label: 'Draft', value: 'draft' },
254
+ { label: 'Published', value: 'published' },
255
+ ],
256
+ db: { type: 'enum' },
257
+ defaultValue: 'draft',
258
+ })
259
+
260
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
261
+ expect(result.modifiers).toBe(' @default(draft)')
262
+ expect(result.modifiers).not.toContain('?')
263
+ })
264
+
265
+ it('should force ? with db.isNullable even when a default is present (enum)', () => {
266
+ const field = select({
267
+ options: [
268
+ { label: 'Draft', value: 'draft' },
269
+ { label: 'Published', value: 'published' },
270
+ ],
271
+ db: { type: 'enum', isNullable: true },
272
+ defaultValue: 'draft',
273
+ })
274
+
275
+ const result = field.getPrismaType!('status', 'sqlite', 'Post')
276
+ expect(result.type).toBe('PostStatus')
277
+ expect(result.modifiers).toBe('? @default(draft)')
278
+ })
279
+
280
+ it('should override the derived enum name with db.enumName', () => {
281
+ const field = select({
282
+ options: [
283
+ { label: 'Open', value: 'open' },
284
+ { label: 'Closed', value: 'closed' },
285
+ ],
286
+ db: { type: 'enum', enumName: 'AccountNoteStatusType' },
287
+ })
288
+
289
+ const result = field.getPrismaType!('status', 'sqlite', 'AccountNote')
290
+ // result.type drives both the enum block name and the column reference
291
+ expect(result.type).toBe('AccountNoteStatusType')
292
+ expect(result.enumValues).toEqual(['open', 'closed'])
293
+ })
294
+
295
+ it('should ignore db.enumName for string (non-enum) selects', () => {
296
+ const field = select({
297
+ options: [{ label: 'Open', value: 'open' }],
298
+ // enumName only applies to native-enum selects; string selects stay String
299
+ db: { enumName: 'ShouldBeIgnored' },
300
+ })
301
+
302
+ const result = field.getPrismaType!('status', 'sqlite', 'AccountNote')
303
+ expect(result.type).toBe('String')
304
+ expect(result.enumValues).toBeUndefined()
305
+ })
306
+
208
307
  it('should include @map modifier for enum field with map option', () => {
209
308
  const field = select({
210
309
  options: [{ label: 'Draft', value: 'draft' }],
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { text, integer } from './index.js'
3
+
4
+ /**
5
+ * Unit coverage for Keystone-compat mode on text() (issue #475).
6
+ *
7
+ * Keystone 6 gives every non-null text column an implicit empty-string default.
8
+ * The `keystoneCompat` flag (db.keystoneCompat) reaches text()'s getPrismaType as
9
+ * the 4th positional argument — the same way provider/listName already do — and
10
+ * emits `@default("")` for a non-null text column that has no explicit default.
11
+ *
12
+ * These tests pin the precise on/off/explicit-default/nullable/non-text matrix
13
+ * from the issue's acceptance criteria.
14
+ */
15
+ describe('text() Keystone-compat empty-string default', () => {
16
+ const KEYSTONE_COMPAT = true
17
+
18
+ describe('with keystoneCompat ON', () => {
19
+ it('emits @default("") for a required (non-null) text field without an explicit default', () => {
20
+ const field = text({ validation: { isRequired: true } })
21
+
22
+ const result = field.getPrismaType!('name', 'sqlite', 'User', KEYSTONE_COMPAT)
23
+
24
+ expect(result.type).toBe('String')
25
+ expect(result.modifiers).toBe('@default("")')
26
+ })
27
+
28
+ it('emits @default("") for a non-null text field made non-null via db.isNullable: false', () => {
29
+ // Non-null at the DB level even though validation does not mark it required.
30
+ const field = text({ db: { isNullable: false } })
31
+
32
+ const result = field.getPrismaType!('phone', 'sqlite', 'User', KEYSTONE_COMPAT)
33
+
34
+ expect(result.modifiers).toBe('@default("")')
35
+ })
36
+
37
+ it('does NOT emit a default for a nullable text field', () => {
38
+ // Optional → nullable; Keystone-compat must leave it alone.
39
+ const field = text()
40
+
41
+ const result = field.getPrismaType!('bio', 'sqlite', 'User', KEYSTONE_COMPAT)
42
+
43
+ expect(result.modifiers).toBe('?')
44
+ expect(result.modifiers).not.toContain('@default')
45
+ })
46
+
47
+ it('does NOT emit a default for a text field made nullable via db.isNullable: true', () => {
48
+ // Required validation, but explicitly nullable at the DB level.
49
+ const field = text({ validation: { isRequired: true }, db: { isNullable: true } })
50
+
51
+ const result = field.getPrismaType!('note', 'sqlite', 'User', KEYSTONE_COMPAT)
52
+
53
+ expect(result.modifiers).toBe('?')
54
+ expect(result.modifiers).not.toContain('@default')
55
+ })
56
+
57
+ it('lets an explicit defaultValue win over the compat empty-string default', () => {
58
+ const field = text({ validation: { isRequired: true }, defaultValue: 'PLEASE_UPDATE' })
59
+
60
+ const result = field.getPrismaType!('status', 'sqlite', 'Account', KEYSTONE_COMPAT)
61
+
62
+ expect(result.modifiers).toBe('@default("PLEASE_UPDATE")')
63
+ expect(result.modifiers).not.toContain('@default("")')
64
+ })
65
+
66
+ it('honours an explicit empty-string defaultValue without double-emitting', () => {
67
+ const field = text({ validation: { isRequired: true }, defaultValue: '' })
68
+
69
+ const result = field.getPrismaType!('label', 'sqlite', 'Account', KEYSTONE_COMPAT)
70
+
71
+ // A single @default("") — the explicit default, not a duplicate.
72
+ expect(result.modifiers).toBe('@default("")')
73
+ expect(result.modifiers!.match(/@default/g)).toHaveLength(1)
74
+ })
75
+
76
+ it('places @default("") alongside other modifiers in the expected order', () => {
77
+ const field = text({
78
+ validation: { isRequired: true },
79
+ isIndexed: 'unique',
80
+ db: { nativeType: 'Text', map: 'full_name' },
81
+ })
82
+
83
+ const result = field.getPrismaType!('fullName', 'postgresql', 'User', KEYSTONE_COMPAT)
84
+
85
+ // nativeType → default → unique → map, matching the builder's modifier order.
86
+ expect(result.modifiers).toBe('@db.Text @default("") @unique @map("full_name")')
87
+ })
88
+ })
89
+
90
+ describe('with keystoneCompat OFF (default)', () => {
91
+ it('emits no default for a required text field when the flag is omitted', () => {
92
+ const field = text({ validation: { isRequired: true } })
93
+
94
+ const result = field.getPrismaType!('name', 'sqlite', 'User')
95
+
96
+ expect(result.modifiers).toBeUndefined()
97
+ })
98
+
99
+ it('emits no default for a required text field when the flag is explicitly false', () => {
100
+ const field = text({ validation: { isRequired: true } })
101
+
102
+ const result = field.getPrismaType!('name', 'sqlite', 'User', false)
103
+
104
+ expect(result.modifiers).toBeUndefined()
105
+ })
106
+
107
+ it('still honours an explicit defaultValue when the flag is off', () => {
108
+ const field = text({ validation: { isRequired: true }, defaultValue: 'PLEASE_UPDATE' })
109
+
110
+ const result = field.getPrismaType!('status', 'sqlite', 'Account', false)
111
+
112
+ expect(result.modifiers).toBe('@default("PLEASE_UPDATE")')
113
+ })
114
+ })
115
+
116
+ describe('non-text fields are unaffected by the flag', () => {
117
+ it('does not give a required integer field an empty-string default under keystoneCompat', () => {
118
+ const field = integer({ validation: { isRequired: true } })
119
+
120
+ const result = field.getPrismaType!('count', 'sqlite', 'Widget', KEYSTONE_COMPAT)
121
+
122
+ expect(result.type).toBe('Int')
123
+ expect(result.modifiers ?? '').not.toContain('@default')
124
+ })
125
+ })
126
+ })
@@ -2,6 +2,7 @@ import type { Hooks } from '../config/types.js'
2
2
  import type { AccessContext } from '../access/types.js'
3
3
  import type { FieldConfig } from '../config/types.js'
4
4
  import { validateWithZod } from '../validation/schema.js'
5
+ import { checkFieldAccess } from '../access/field-access.js'
5
6
 
6
7
  /**
7
8
  * Validation error collection
@@ -235,24 +236,66 @@ export async function executeFieldResolveInputHooks(
235
236
  // Skip if field not in data
236
237
  if (!(fieldKey in result)) continue
237
238
 
238
- // Skip if no hooks defined
239
- if (!fieldConfig.hooks?.resolveInput) continue
239
+ // A field's resolveInput produces its resolved value; for most fields that
240
+ // value is stored back under the same key. Multi-column fields additionally
241
+ // split that value across their physical columns below.
242
+ let resolvedValue: unknown = result[fieldKey]
240
243
 
241
- // Execute field hook
242
- // Type assertion is safe here because hooks are typed correctly in field definitions
243
- // and we're working with runtime values that match those types
244
- const transformedValue = await fieldConfig.hooks.resolveInput({
245
- listKey,
246
- fieldKey,
247
- operation,
248
- inputData,
249
- item,
250
- resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
251
- context,
252
- } as Parameters<typeof fieldConfig.hooks.resolveInput>[0])
253
-
254
- // Create new object with updated field to avoid mutating the passed reference
255
- result = { ...result, [fieldKey]: transformedValue }
244
+ if (fieldConfig.hooks?.resolveInput) {
245
+ // Execute field hook
246
+ // Type assertion is safe here because hooks are typed correctly in field definitions
247
+ // and we're working with runtime values that match those types
248
+ resolvedValue = await fieldConfig.hooks.resolveInput({
249
+ listKey,
250
+ fieldKey,
251
+ operation,
252
+ inputData,
253
+ item,
254
+ resolvedData: { ...result }, // Pass a copy to avoid mutation affecting recorded args
255
+ context,
256
+ } as Parameters<typeof fieldConfig.hooks.resolveInput>[0])
257
+ } else if (!fieldConfig.splitColumns) {
258
+ // No resolveInput and not a multi-column field — nothing to do.
259
+ continue
260
+ }
261
+
262
+ if (fieldConfig.splitColumns) {
263
+ // Multi-column field (e.g. storage image()/file() in Keystone-parity
264
+ // mode): replace the single logical key with its per-part columns so the
265
+ // write payload targets the live columns instead of a single one.
266
+ //
267
+ // The split removes the logical key from the payload BEFORE the
268
+ // canonical writable-field filter (`filterWritableFields`) runs, and the
269
+ // raw per-part column keys are not in `fieldConfigs` — so that later
270
+ // filter cannot enforce this field's own write access. Enforce it HERE,
271
+ // using the canonical field-access evaluator with the SAME arguments the
272
+ // write pipeline uses. A single-column field denied by `update`/`create`
273
+ // is simply omitted from the write; a denied multi-column field must
274
+ // likewise contribute NONE of its per-part columns. (sudo bypasses via
275
+ // `checkFieldAccess`.)
276
+ const canWrite = await checkFieldAccess(fieldConfig.access, operation, {
277
+ session: context.session,
278
+ item,
279
+ context,
280
+ inputData,
281
+ })
282
+ if (!canWrite) {
283
+ // Denied: drop the logical key and write none of its columns — exactly
284
+ // as filterWritableFields drops a denied single-column field.
285
+ const next = { ...result }
286
+ delete next[fieldKey]
287
+ result = next
288
+ continue
289
+ }
290
+ const columns = fieldConfig.splitColumns(fieldKey, resolvedValue)
291
+ // Drop the logical key (it is not a real column) and merge the columns.
292
+ const next = { ...result, ...columns }
293
+ delete next[fieldKey]
294
+ result = next
295
+ } else {
296
+ // Create new object with updated field to avoid mutating the passed reference
297
+ result = { ...result, [fieldKey]: resolvedValue }
298
+ }
256
299
  }
257
300
 
258
301
  return result
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect, expectTypeOf } from 'vitest'
2
+ // Import from the package root entry point exactly as the docs / CHANGELOG /
3
+ // migrate-context-calls skill instruct consumers to. If the root `index.ts`
4
+ // stops re-exporting the query API, this file fails to type-check / run,
5
+ // preventing a silent regression (issue #496).
6
+ import { defineFragment, runQuery, runQueryOne } from './index.js'
7
+ import type { ResultOf, RelationSelector, QueryArgs } from './index.js'
8
+
9
+ // ─────────────────────────────────────────────────────────────
10
+ // Stand-in model type (mirrors a Prisma-generated type)
11
+ // ─────────────────────────────────────────────────────────────
12
+
13
+ type User = {
14
+ id: string
15
+ name: string
16
+ email: string
17
+ }
18
+
19
+ describe('@opensaas/stack-core root entry point — query API re-exports (issue #496)', () => {
20
+ it('re-exports the runtime query functions from the package root', () => {
21
+ expect(typeof defineFragment).toBe('function')
22
+ expect(typeof runQuery).toBe('function')
23
+ expect(typeof runQueryOne).toBe('function')
24
+ })
25
+
26
+ it('defineFragment imported from the root produces a usable fragment', () => {
27
+ const userFragment = defineFragment<User>()({ id: true, name: true } as const)
28
+
29
+ expect(userFragment._type).toBe('fragment')
30
+ expect(userFragment._fields).toEqual({ id: true, name: true })
31
+ })
32
+
33
+ it('exposes ResultOf as a usable type alias from the root', () => {
34
+ const userFragment = defineFragment<User>()({ id: true, name: true } as const)
35
+ expect(userFragment._type).toBe('fragment')
36
+
37
+ // Type-level assertion: ResultOf<typeof fragment> narrows to the selection.
38
+ expectTypeOf<ResultOf<typeof userFragment>>().toEqualTypeOf<{ id: string; name: string }>()
39
+ })
40
+
41
+ it('exposes QueryArgs and RelationSelector as usable types from the root', () => {
42
+ // Type-level usage — these annotations only compile if the types resolve
43
+ // from the root entry point.
44
+ const args: QueryArgs = { where: { id: 'abc' }, take: 5 }
45
+ expect(args.take).toBe(5)
46
+
47
+ const selector: RelationSelector<User> = defineFragment<User>()({ id: true } as const)
48
+ expectTypeOf(selector).not.toBeNever()
49
+ })
50
+ })
package/src/index.ts CHANGED
@@ -14,7 +14,14 @@ export { config, list } from './config/index.js'
14
14
  // Config types a consumer annotates with.
15
15
  // Concrete field-config types (TextField, …) live on '@opensaas/stack-core/fields'
16
16
  // alongside their builders.
17
- export type { OpenSaasConfig, ListConfig, FieldConfig, OperationAccess } from './config/index.js'
17
+ export type {
18
+ OpenSaasConfig,
19
+ OutputConfig,
20
+ ListConfig,
21
+ DatabaseConfig,
22
+ FieldConfig,
23
+ OperationAccess,
24
+ } from './config/index.js'
18
25
 
19
26
  // Access control — the types a consumer writes against
20
27
  export type {
@@ -40,3 +47,12 @@ export { ValidationError } from './hooks/index.js'
40
47
  // with a clear per-field message instead of deep inside generation.
41
48
  export { validateFieldConfig, validateConfigFields } from './validation/field-config.js'
42
49
  export type { FieldConfigValidationError } from './validation/field-config.js'
50
+
51
+ // Fragment-based query API — composable, type-safe reads that mirror
52
+ // Keystone's GraphQL fragments without a GraphQL runtime. The migration
53
+ // guide, CHANGELOG, and migrate-context-calls skill all advertise importing
54
+ // these from the root entry point. The internal runtime helpers (isFragment,
55
+ // buildInclude, pickFields) and the Fragment/FieldSelection types stay off the
56
+ // root surface — those live on '@opensaas/stack-core/internal'.
57
+ export { defineFragment, runQuery, runQueryOne } from './query/index.js'
58
+ export type { ResultOf, RelationSelector, QueryArgs } from './query/index.js'
@@ -410,8 +410,6 @@ async function handleToolsCall(
410
410
  const toolName = params?.name
411
411
  const toolArgs = params?.arguments || {}
412
412
 
413
- console.log('Handling tool call:', toolName, toolArgs)
414
-
415
413
  if (!toolName) {
416
414
  return new Response(
417
415
  JSON.stringify({