@opensaas/stack-core 0.21.0 → 0.23.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +268 -0
- package/CLAUDE.md +18 -15
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +29 -6
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/multi-column-read-write.test.d.ts +2 -0
- package/dist/access/multi-column-read-write.test.d.ts.map +1 -0
- package/dist/access/multi-column-read-write.test.js +149 -0
- package/dist/access/multi-column-read-write.test.js.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +289 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +31 -0
- package/dist/context/index.js.map +1 -1
- package/dist/extend.d.ts +1 -1
- package/dist/extend.d.ts.map +1 -1
- package/dist/fields/format-prisma-default.d.ts +35 -0
- package/dist/fields/format-prisma-default.d.ts.map +1 -0
- package/dist/fields/format-prisma-default.js +52 -0
- package/dist/fields/format-prisma-default.js.map +1 -0
- package/dist/fields/format-prisma-default.test.d.ts +2 -0
- package/dist/fields/format-prisma-default.test.d.ts.map +1 -0
- package/dist/fields/format-prisma-default.test.js +54 -0
- package/dist/fields/format-prisma-default.test.js.map +1 -0
- package/dist/fields/index.d.ts +1 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +54 -16
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/select.test.js +85 -0
- package/dist/fields/select.test.js.map +1 -1
- package/dist/fields/text-keystone-compat.test.d.ts +2 -0
- package/dist/fields/text-keystone-compat.test.d.ts.map +1 -0
- package/dist/fields/text-keystone-compat.test.js +93 -0
- package/dist/fields/text-keystone-compat.test.js.map +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +60 -16
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +33 -0
- package/dist/index.test.js.map +1 -0
- package/dist/mcp/handler.js +0 -1
- package/dist/mcp/handler.js.map +1 -1
- package/package.json +1 -1
- package/src/access/field-visibility.ts +28 -6
- package/src/access/multi-column-read-write.test.ts +255 -0
- package/src/config/index.ts +2 -0
- package/src/config/types.ts +291 -0
- package/src/context/index.ts +45 -0
- package/src/extend.ts +6 -1
- package/src/fields/format-prisma-default.test.ts +64 -0
- package/src/fields/format-prisma-default.ts +67 -0
- package/src/fields/index.ts +65 -18
- package/src/fields/select.test.ts +99 -0
- package/src/fields/text-keystone-compat.test.ts +126 -0
- package/src/hooks/index.ts +60 -17
- package/src/index.test.ts +50 -0
- package/src/index.ts +17 -1
- package/src/mcp/handler.ts +0 -2
- package/tests/context.test.ts +80 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { formatPrismaDefault, type PrismaDefaultFieldType } from './format-prisma-default.js'
|
|
3
|
+
|
|
4
|
+
describe('formatPrismaDefault', () => {
|
|
5
|
+
describe('table-driven serialisation', () => {
|
|
6
|
+
const cases: Array<{
|
|
7
|
+
name: string
|
|
8
|
+
value: unknown
|
|
9
|
+
fieldType: PrismaDefaultFieldType
|
|
10
|
+
expected: string | undefined
|
|
11
|
+
}> = [
|
|
12
|
+
// text
|
|
13
|
+
{
|
|
14
|
+
name: 'non-empty string',
|
|
15
|
+
value: 'PLEASE_UPDATE',
|
|
16
|
+
fieldType: 'text',
|
|
17
|
+
expected: '"PLEASE_UPDATE"',
|
|
18
|
+
},
|
|
19
|
+
{ name: 'empty string', value: '', fieldType: 'text', expected: '""' },
|
|
20
|
+
{
|
|
21
|
+
name: 'string with embedded quotes',
|
|
22
|
+
value: 'say "hi"',
|
|
23
|
+
fieldType: 'text',
|
|
24
|
+
expected: '"say \\"hi\\""',
|
|
25
|
+
},
|
|
26
|
+
// integer
|
|
27
|
+
{ name: 'integer', value: 3550, fieldType: 'integer', expected: '3550' },
|
|
28
|
+
{ name: 'zero integer', value: 0, fieldType: 'integer', expected: '0' },
|
|
29
|
+
{ name: 'negative integer', value: -7, fieldType: 'integer', expected: '-7' },
|
|
30
|
+
// json
|
|
31
|
+
{ name: 'JSON array', value: [1, 2, 3, 4, 5], fieldType: 'json', expected: '"[1,2,3,4,5]"' },
|
|
32
|
+
{
|
|
33
|
+
name: 'JSON object',
|
|
34
|
+
value: { a: 1, b: 'two' },
|
|
35
|
+
fieldType: 'json',
|
|
36
|
+
expected: '"{\\"a\\":1,\\"b\\":\\"two\\"}"',
|
|
37
|
+
},
|
|
38
|
+
{ name: 'empty array', value: [], fieldType: 'json', expected: '"[]"' },
|
|
39
|
+
{ name: 'empty object', value: {}, fieldType: 'json', expected: '"{}"' },
|
|
40
|
+
// undefined → no default for every field type
|
|
41
|
+
{ name: 'undefined text', value: undefined, fieldType: 'text', expected: undefined },
|
|
42
|
+
{ name: 'undefined integer', value: undefined, fieldType: 'integer', expected: undefined },
|
|
43
|
+
{ name: 'undefined json', value: undefined, fieldType: 'json', expected: undefined },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
it.each(cases)('serialises $name → $expected', ({ value, fieldType, expected }) => {
|
|
47
|
+
expect(formatPrismaDefault(value, fieldType)).toBe(expected)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('produces canonical space-free JSON (no extra whitespace)', () => {
|
|
52
|
+
// Guards against pretty-printed JSON sneaking into the literal.
|
|
53
|
+
expect(formatPrismaDefault([1, 2, 3], 'json')).toBe('"[1,2,3]"')
|
|
54
|
+
expect(formatPrismaDefault({ nested: { x: [1] } }, 'json')).toBe(
|
|
55
|
+
'"{\\"nested\\":{\\"x\\":[1]}}"',
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('wraps a JSON string default in escaped quotes around the JSON text', () => {
|
|
60
|
+
// A string value under the json field type is itself valid JSON; it gets
|
|
61
|
+
// double-serialised: inner JSON.stringify("hi") = "\"hi\"", then wrapped.
|
|
62
|
+
expect(formatPrismaDefault('hi', 'json')).toBe('"\\"hi\\""')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field types that {@link formatPrismaDefault} knows how to serialise.
|
|
3
|
+
*
|
|
4
|
+
* Kept narrow on purpose: only the scalar fields whose `defaultValue` maps to a
|
|
5
|
+
* Prisma `@default(...)` literal via this shared helper. Other fields (e.g.
|
|
6
|
+
* `checkbox`, `decimal`, `timestamp`) format their own defaults inline because
|
|
7
|
+
* their literal forms diverge (`@default(now())`, bare booleans, etc.).
|
|
8
|
+
*/
|
|
9
|
+
export type PrismaDefaultFieldType = 'text' | 'integer' | 'json'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Serialise a field's `defaultValue` into the inner literal of a Prisma
|
|
13
|
+
* `@default(...)` attribute.
|
|
14
|
+
*
|
|
15
|
+
* Pure: no I/O, no field-builder coupling. Returns just the literal (the caller
|
|
16
|
+
* wraps it in `@default(...)`), so it composes with whatever modifier string a
|
|
17
|
+
* field builder assembles. Returns `undefined` when there is nothing to emit, so
|
|
18
|
+
* a field with no `defaultValue` produces no `@default(...)` at all.
|
|
19
|
+
*
|
|
20
|
+
* Serialisation rules (Keystone 6 compatible):
|
|
21
|
+
* - `integer` → bare numeric literal, e.g. `3550` → `@default(3550)`.
|
|
22
|
+
* - `text` → double-quoted string literal, e.g. `PLEASE_UPDATE` → `@default("PLEASE_UPDATE")`.
|
|
23
|
+
* - `json` → Keystone's JSON-literal form: `JSON.stringify` the value with no
|
|
24
|
+
* extra whitespace, then wrap the result in escaped double quotes, e.g.
|
|
25
|
+
* `[1,2,3,4,5]` → `@default("[1,2,3,4,5]")` and `[]` → `@default("[]")`.
|
|
26
|
+
*
|
|
27
|
+
* Nullability (the `?` modifier) is the caller's concern and is handled
|
|
28
|
+
* independently of the default — this function never touches it.
|
|
29
|
+
*
|
|
30
|
+
* @param value - The configured `defaultValue` (the field builder's `defaultValue`).
|
|
31
|
+
* @param fieldType - The field's discriminator, selecting the serialisation rule.
|
|
32
|
+
* @returns The literal to place inside `@default(...)`, or `undefined` when
|
|
33
|
+
* `value` is `undefined` (no default to emit).
|
|
34
|
+
*/
|
|
35
|
+
export function formatPrismaDefault(
|
|
36
|
+
value: unknown,
|
|
37
|
+
fieldType: PrismaDefaultFieldType,
|
|
38
|
+
): string | undefined {
|
|
39
|
+
if (value === undefined) {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
switch (fieldType) {
|
|
44
|
+
case 'integer':
|
|
45
|
+
// Bare numeric literal — Prisma expects no quotes for Int defaults.
|
|
46
|
+
return String(value)
|
|
47
|
+
|
|
48
|
+
case 'text':
|
|
49
|
+
// Double-quoted string literal. The value is escaped via JSON.stringify so
|
|
50
|
+
// embedded quotes/backslashes are handled correctly.
|
|
51
|
+
return JSON.stringify(String(value))
|
|
52
|
+
|
|
53
|
+
case 'json': {
|
|
54
|
+
// Keystone's JSON-literal form: canonical, space-free JSON.stringify of the
|
|
55
|
+
// value, then wrap the whole serialised string in escaped double quotes so
|
|
56
|
+
// Prisma stores the JSON text as the column default. The outer
|
|
57
|
+
// JSON.stringify produces the escaped, double-quoted wrapper.
|
|
58
|
+
const serialised = JSON.stringify(value)
|
|
59
|
+
// JSON.stringify can return undefined for unserialisable values (e.g. a
|
|
60
|
+
// function). Treat that as "no default" rather than emitting `@default()`.
|
|
61
|
+
if (serialised === undefined) {
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
return JSON.stringify(serialised)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/fields/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
PrismaRelationResult,
|
|
17
17
|
} from '../config/types.js'
|
|
18
18
|
import { hashPassword, isHashedPassword, HashedPassword } from '../utils/password.js'
|
|
19
|
+
import { formatPrismaDefault } from './format-prisma-default.js'
|
|
19
20
|
|
|
20
21
|
// Field-config types live here, alongside the builders that produce them.
|
|
21
22
|
// (The umbrella `FieldConfig` and authoring `BaseFieldConfig` stay on the root
|
|
@@ -33,6 +34,7 @@ export type {
|
|
|
33
34
|
JsonField,
|
|
34
35
|
VirtualField,
|
|
35
36
|
PrismaRelationResult,
|
|
37
|
+
MultiColumnPrismaResult,
|
|
36
38
|
} from '../config/types.js'
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -87,7 +89,12 @@ export function text<
|
|
|
87
89
|
|
|
88
90
|
return !isRequired ? withMax.optional().nullable() : withMax
|
|
89
91
|
},
|
|
90
|
-
getPrismaType: (
|
|
92
|
+
getPrismaType: (
|
|
93
|
+
_fieldName: string,
|
|
94
|
+
_provider?: string,
|
|
95
|
+
_listName?: string,
|
|
96
|
+
keystoneCompat?: boolean,
|
|
97
|
+
) => {
|
|
91
98
|
const validation = options?.validation
|
|
92
99
|
const db = options?.db
|
|
93
100
|
const isRequired = validation?.isRequired
|
|
@@ -104,6 +111,23 @@ export function text<
|
|
|
104
111
|
modifiers += ` @db.${db.nativeType}`
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
// Default value. An explicit `defaultValue` always wins. When none is set
|
|
115
|
+
// and Keystone-compat mode is on, a non-null text column gets Keystone's
|
|
116
|
+
// implicit empty-string default. Both go through formatPrismaDefault, so
|
|
117
|
+
// the empty-string literal (`""`) is produced the same way as any other
|
|
118
|
+
// text default. Independent of the nullable `?` modifier above — the
|
|
119
|
+
// default never overwrites nullability.
|
|
120
|
+
const defaultSource =
|
|
121
|
+
options?.defaultValue !== undefined
|
|
122
|
+
? options.defaultValue
|
|
123
|
+
: keystoneCompat && !isNullable
|
|
124
|
+
? ''
|
|
125
|
+
: undefined
|
|
126
|
+
const defaultLiteral = formatPrismaDefault(defaultSource, 'text')
|
|
127
|
+
if (defaultLiteral !== undefined) {
|
|
128
|
+
modifiers += ` @default(${defaultLiteral})`
|
|
129
|
+
}
|
|
130
|
+
|
|
107
131
|
// Unique/index modifiers
|
|
108
132
|
if (options?.isIndexed === 'unique') {
|
|
109
133
|
modifiers += ' @unique'
|
|
@@ -182,6 +206,13 @@ export function integer<
|
|
|
182
206
|
modifiers += ` @db.${db.nativeType}`
|
|
183
207
|
}
|
|
184
208
|
|
|
209
|
+
// Default value if provided (bare numeric literal). Independent of the
|
|
210
|
+
// nullable `?` modifier above — the default never overwrites nullability.
|
|
211
|
+
const defaultLiteral = formatPrismaDefault(options?.defaultValue, 'integer')
|
|
212
|
+
if (defaultLiteral !== undefined) {
|
|
213
|
+
modifiers += ` @default(${defaultLiteral})`
|
|
214
|
+
}
|
|
215
|
+
|
|
185
216
|
// Map modifier
|
|
186
217
|
if (db?.map) {
|
|
187
218
|
modifiers += ` @map("${db.map}")`
|
|
@@ -677,7 +708,6 @@ export function password<TTypeInfo extends import('../config/types.js').TypeInfo
|
|
|
677
708
|
resolveInput: async ({ inputData, fieldKey }: { inputData: any; fieldKey: string }) => {
|
|
678
709
|
// Skip if undefined or null (allows partial updates)
|
|
679
710
|
const inputValue = inputData[fieldKey]
|
|
680
|
-
console.log('Password resolveInput called with value:', inputValue)
|
|
681
711
|
if (inputValue === undefined || inputValue === null) {
|
|
682
712
|
return inputValue
|
|
683
713
|
}
|
|
@@ -823,21 +853,35 @@ export function select<
|
|
|
823
853
|
},
|
|
824
854
|
getPrismaType: (fieldName: string, _provider?: string, listName?: string) => {
|
|
825
855
|
const isRequired = options.validation?.isRequired
|
|
856
|
+
const hasDefault = options.defaultValue !== undefined
|
|
857
|
+
// Nullability rules (Keystone parity):
|
|
858
|
+
// - `db.isNullable` is an explicit override and always wins. Setting it
|
|
859
|
+
// `true` forces the `?` even when a `defaultValue` is present.
|
|
860
|
+
// - Otherwise a select is nullable only when it is neither required nor
|
|
861
|
+
// carrying a default: a `defaultValue` makes the column NOT NULL (the
|
|
862
|
+
// long-standing default behaviour). This mirrors the previous logic
|
|
863
|
+
// where a present default overwrote the `?`.
|
|
864
|
+
// Nullability and the default are assembled independently with `+=`
|
|
865
|
+
// (mirroring text/integer) so the default never overwrites the `?`.
|
|
866
|
+
const isNullable = options.db?.isNullable ?? (!isRequired && !hasDefault)
|
|
826
867
|
let modifiers = ''
|
|
827
868
|
|
|
869
|
+
// Optional modifier
|
|
870
|
+
if (isNullable) {
|
|
871
|
+
modifiers += '?'
|
|
872
|
+
}
|
|
873
|
+
|
|
828
874
|
if (isNativeEnum) {
|
|
829
|
-
//
|
|
875
|
+
// Enum type name: explicit `db.enumName` wins, otherwise derive from
|
|
876
|
+
// list name + field name in PascalCase. The same name is used for the
|
|
877
|
+
// generated enum block (via `result.type`) and the column reference.
|
|
830
878
|
const capitalizedField = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
|
|
831
|
-
const
|
|
832
|
-
|
|
833
|
-
// Required fields don't get the ? modifier
|
|
834
|
-
if (!isRequired) {
|
|
835
|
-
modifiers = '?'
|
|
836
|
-
}
|
|
879
|
+
const derivedEnumName = listName ? `${listName}${capitalizedField}` : capitalizedField
|
|
880
|
+
const enumName = options.db?.enumName ?? derivedEnumName
|
|
837
881
|
|
|
838
882
|
// Add default value if provided (no quotes for enum values)
|
|
839
|
-
if (
|
|
840
|
-
modifiers
|
|
883
|
+
if (hasDefault) {
|
|
884
|
+
modifiers += ` @default(${options.defaultValue})`
|
|
841
885
|
}
|
|
842
886
|
|
|
843
887
|
// Map modifier
|
|
@@ -854,14 +898,9 @@ export function select<
|
|
|
854
898
|
|
|
855
899
|
// String type (default)
|
|
856
900
|
|
|
857
|
-
// Required fields don't get the ? modifier
|
|
858
|
-
if (!isRequired) {
|
|
859
|
-
modifiers = '?'
|
|
860
|
-
}
|
|
861
|
-
|
|
862
901
|
// Add default value if provided
|
|
863
|
-
if (
|
|
864
|
-
modifiers
|
|
902
|
+
if (hasDefault) {
|
|
903
|
+
modifiers += ` @default("${options.defaultValue}")`
|
|
865
904
|
}
|
|
866
905
|
|
|
867
906
|
// Map modifier
|
|
@@ -1300,6 +1339,14 @@ export function json<
|
|
|
1300
1339
|
modifiers += ` @db.${db.nativeType}`
|
|
1301
1340
|
}
|
|
1302
1341
|
|
|
1342
|
+
// Default value if provided. Uses Keystone's JSON-literal form: canonical
|
|
1343
|
+
// (space-free) JSON wrapped in escaped double quotes. Independent of the
|
|
1344
|
+
// nullable `?` modifier above — the default never overwrites nullability.
|
|
1345
|
+
const defaultLiteral = formatPrismaDefault(options?.defaultValue, 'json')
|
|
1346
|
+
if (defaultLiteral !== undefined) {
|
|
1347
|
+
modifiers += ` @default(${defaultLiteral})`
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1303
1350
|
// Map modifier
|
|
1304
1351
|
if (db?.map) {
|
|
1305
1352
|
modifiers += ` @map("${db.map}")`
|
|
@@ -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
|
+
})
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
-
//
|
|
239
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
+
})
|