@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,255 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { filterReadableFields } from './field-visibility.js'
|
|
3
|
+
import { executeFieldResolveInputHooks } from '../hooks/index.js'
|
|
4
|
+
import type { FieldConfig } from '../config/types.js'
|
|
5
|
+
import type { AccessContext, FieldAccess } from './types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generic core wiring for multi-column fields (the contract storage
|
|
9
|
+
* image()/file() use in Keystone-parity mode — see ADR-0006). These tests are
|
|
10
|
+
* field-agnostic: they assert that ANY field implementing
|
|
11
|
+
* getColumnNames/assembleColumns/splitColumns is assembled on read (raw columns
|
|
12
|
+
* stripped) and split on write.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// A minimal multi-column field: two physical columns `m_url` and `m_size`
|
|
16
|
+
// assembled into `{ url, size }` and split back. Optionally carries field-level
|
|
17
|
+
// access so we can lock the write-access gate around the split.
|
|
18
|
+
function multiColumnField(access?: FieldAccess): FieldConfig {
|
|
19
|
+
const COLUMNS = ['m_url', 'm_size']
|
|
20
|
+
return {
|
|
21
|
+
type: 'multiColumn',
|
|
22
|
+
access,
|
|
23
|
+
getColumnNames: () => COLUMNS,
|
|
24
|
+
assembleColumns: (_fieldName: string, row: Record<string, unknown>) => {
|
|
25
|
+
const url = row.m_url
|
|
26
|
+
if (url === null || url === undefined || url === '') return null
|
|
27
|
+
return { url, size: row.m_size ?? 0 }
|
|
28
|
+
},
|
|
29
|
+
splitColumns: (_fieldName: string, value: unknown) => {
|
|
30
|
+
if (value === null || value === undefined) {
|
|
31
|
+
return { m_url: null, m_size: null }
|
|
32
|
+
}
|
|
33
|
+
const v = value as { url?: unknown; size?: unknown }
|
|
34
|
+
return { m_url: v.url ?? null, m_size: v.size ?? null }
|
|
35
|
+
},
|
|
36
|
+
// Field's own resolveInput is identity here (the value is authoritative).
|
|
37
|
+
hooks: {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic test hook
|
|
39
|
+
resolveInput: async ({ resolvedData, fieldKey }: any) => resolvedData?.[fieldKey],
|
|
40
|
+
},
|
|
41
|
+
} as unknown as FieldConfig
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeContext(overrides: { isSudo?: boolean } = {}): AccessContext {
|
|
45
|
+
return {
|
|
46
|
+
session: null,
|
|
47
|
+
_isSudo: overrides.isSudo ?? false,
|
|
48
|
+
_resolveOutputCounter: { depth: 0 },
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
|
|
50
|
+
} as any
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('multi-column read assembly (filterReadableFields)', () => {
|
|
54
|
+
const fields = { media: multiColumnField() }
|
|
55
|
+
|
|
56
|
+
it('assembles the per-part columns into the logical field and strips the raw columns', async () => {
|
|
57
|
+
const row = { id: 'a', m_url: 'https://x/y.jpg', m_size: 99, title: 'hi' }
|
|
58
|
+
const result = await filterReadableFields(
|
|
59
|
+
row,
|
|
60
|
+
// title is a plain field
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- inline field configs
|
|
62
|
+
{ ...fields, title: { type: 'text' } as any },
|
|
63
|
+
{ session: null, context: makeContext() },
|
|
64
|
+
undefined,
|
|
65
|
+
0,
|
|
66
|
+
'Post',
|
|
67
|
+
)
|
|
68
|
+
expect(result).toEqual({ id: 'a', title: 'hi', media: { url: 'https://x/y.jpg', size: 99 } })
|
|
69
|
+
// Raw columns must NOT leak.
|
|
70
|
+
expect('m_url' in result).toBe(false)
|
|
71
|
+
expect('m_size' in result).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('assembles a partially-populated row (only m_url present)', async () => {
|
|
75
|
+
const row = { id: 'b', m_url: 'https://x/only.jpg' }
|
|
76
|
+
const result = await filterReadableFields(
|
|
77
|
+
row,
|
|
78
|
+
fields,
|
|
79
|
+
{ session: null, context: makeContext() },
|
|
80
|
+
undefined,
|
|
81
|
+
0,
|
|
82
|
+
'Post',
|
|
83
|
+
)
|
|
84
|
+
expect(result).toEqual({ id: 'b', media: { url: 'https://x/only.jpg', size: 0 } })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('yields a null logical value when the columns are empty', async () => {
|
|
88
|
+
const row = { id: 'c', m_url: null, m_size: null }
|
|
89
|
+
const result = await filterReadableFields(
|
|
90
|
+
row,
|
|
91
|
+
fields,
|
|
92
|
+
{ session: null, context: makeContext() },
|
|
93
|
+
undefined,
|
|
94
|
+
0,
|
|
95
|
+
'Post',
|
|
96
|
+
)
|
|
97
|
+
expect(result).toEqual({ id: 'c', media: null })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('leaves the field absent when its columns were not selected', async () => {
|
|
101
|
+
const row = { id: 'd', title: 'no media columns' }
|
|
102
|
+
const result = await filterReadableFields(
|
|
103
|
+
row,
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- inline field configs
|
|
105
|
+
{ ...fields, title: { type: 'text' } as any },
|
|
106
|
+
{ session: null, context: makeContext() },
|
|
107
|
+
undefined,
|
|
108
|
+
0,
|
|
109
|
+
'Post',
|
|
110
|
+
)
|
|
111
|
+
expect(result).toEqual({ id: 'd', title: 'no media columns' })
|
|
112
|
+
expect('media' in result).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('multi-column write split (executeFieldResolveInputHooks)', () => {
|
|
117
|
+
const fields = { media: multiColumnField() }
|
|
118
|
+
|
|
119
|
+
it('splits the logical value into per-part columns and removes the logical key', async () => {
|
|
120
|
+
const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
|
|
121
|
+
const result = await executeFieldResolveInputHooks(
|
|
122
|
+
inputData,
|
|
123
|
+
{ ...inputData },
|
|
124
|
+
fields,
|
|
125
|
+
'create',
|
|
126
|
+
makeContext(),
|
|
127
|
+
'Post',
|
|
128
|
+
)
|
|
129
|
+
expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
|
|
130
|
+
expect('media' in result).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('splitting null clears all per-part columns', async () => {
|
|
134
|
+
const inputData = { media: null }
|
|
135
|
+
const result = await executeFieldResolveInputHooks(
|
|
136
|
+
inputData,
|
|
137
|
+
{ ...inputData },
|
|
138
|
+
fields,
|
|
139
|
+
'update',
|
|
140
|
+
makeContext(),
|
|
141
|
+
'Post',
|
|
142
|
+
)
|
|
143
|
+
expect(result).toEqual({ m_url: null, m_size: null })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('does not touch the columns when the logical field is absent from the write', async () => {
|
|
147
|
+
const inputData = { title: 'no media in payload' }
|
|
148
|
+
const result = await executeFieldResolveInputHooks(
|
|
149
|
+
inputData,
|
|
150
|
+
{ ...inputData },
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- inline field configs
|
|
152
|
+
{ ...fields, title: { type: 'text' } as any },
|
|
153
|
+
'update',
|
|
154
|
+
makeContext(),
|
|
155
|
+
'Post',
|
|
156
|
+
)
|
|
157
|
+
expect(result).toEqual({ title: 'no media in payload' })
|
|
158
|
+
expect('m_url' in result).toBe(false)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
describe('multi-column write split respects field-level write access', () => {
|
|
163
|
+
it('does NOT write any per-part columns when update access is denied', async () => {
|
|
164
|
+
const fields = { media: multiColumnField({ update: () => false }) }
|
|
165
|
+
const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
|
|
166
|
+
const result = await executeFieldResolveInputHooks(
|
|
167
|
+
inputData,
|
|
168
|
+
{ ...inputData },
|
|
169
|
+
fields,
|
|
170
|
+
'update',
|
|
171
|
+
makeContext(),
|
|
172
|
+
'Post',
|
|
173
|
+
)
|
|
174
|
+
// The logical key is dropped (it is not a real column) AND none of its
|
|
175
|
+
// per-part columns are written — identical to how filterWritableFields
|
|
176
|
+
// drops a denied single-column field.
|
|
177
|
+
expect(result).toEqual({})
|
|
178
|
+
expect('media' in result).toBe(false)
|
|
179
|
+
expect('m_url' in result).toBe(false)
|
|
180
|
+
expect('m_size' in result).toBe(false)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('does NOT write any per-part columns when create access is denied', async () => {
|
|
184
|
+
const fields = { media: multiColumnField({ create: () => false }) }
|
|
185
|
+
const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
|
|
186
|
+
const result = await executeFieldResolveInputHooks(
|
|
187
|
+
inputData,
|
|
188
|
+
{ ...inputData },
|
|
189
|
+
fields,
|
|
190
|
+
'create',
|
|
191
|
+
makeContext(),
|
|
192
|
+
'Post',
|
|
193
|
+
)
|
|
194
|
+
expect(result).toEqual({})
|
|
195
|
+
expect('m_url' in result).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('still splits/writes the columns when write access is granted', async () => {
|
|
199
|
+
const fields = { media: multiColumnField({ update: () => true, create: () => true }) }
|
|
200
|
+
const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
|
|
201
|
+
const result = await executeFieldResolveInputHooks(
|
|
202
|
+
inputData,
|
|
203
|
+
{ ...inputData },
|
|
204
|
+
fields,
|
|
205
|
+
'update',
|
|
206
|
+
makeContext(),
|
|
207
|
+
'Post',
|
|
208
|
+
)
|
|
209
|
+
expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
|
|
210
|
+
expect('media' in result).toBe(false)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('denying the OTHER operation does not block the write (update field, create op)', async () => {
|
|
214
|
+
// A field that denies `update` must still be writable on `create`.
|
|
215
|
+
const fields = { media: multiColumnField({ update: () => false }) }
|
|
216
|
+
const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
|
|
217
|
+
const result = await executeFieldResolveInputHooks(
|
|
218
|
+
inputData,
|
|
219
|
+
{ ...inputData },
|
|
220
|
+
fields,
|
|
221
|
+
'create',
|
|
222
|
+
makeContext(),
|
|
223
|
+
'Post',
|
|
224
|
+
)
|
|
225
|
+
expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('sudo bypasses the field-access gate and still splits', async () => {
|
|
229
|
+
const fields = { media: multiColumnField({ update: () => false }) }
|
|
230
|
+
const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
|
|
231
|
+
const result = await executeFieldResolveInputHooks(
|
|
232
|
+
inputData,
|
|
233
|
+
{ ...inputData },
|
|
234
|
+
fields,
|
|
235
|
+
'update',
|
|
236
|
+
makeContext({ isSudo: true }),
|
|
237
|
+
'Post',
|
|
238
|
+
)
|
|
239
|
+
expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('a multi-column field WITHOUT field-level access splits exactly as before', async () => {
|
|
243
|
+
const fields = { media: multiColumnField() }
|
|
244
|
+
const inputData = { media: { url: 'https://x/y.jpg', size: 99 } }
|
|
245
|
+
const result = await executeFieldResolveInputHooks(
|
|
246
|
+
inputData,
|
|
247
|
+
{ ...inputData },
|
|
248
|
+
fields,
|
|
249
|
+
'update',
|
|
250
|
+
makeContext(),
|
|
251
|
+
'Post',
|
|
252
|
+
)
|
|
253
|
+
expect(result).toEqual({ m_url: 'https://x/y.jpg', m_size: 99 })
|
|
254
|
+
})
|
|
255
|
+
})
|
package/src/config/index.ts
CHANGED
|
@@ -122,6 +122,7 @@ export function list<TTypeInfo extends import('./types.js').TypeInfo>(
|
|
|
122
122
|
// Re-export all types
|
|
123
123
|
export type {
|
|
124
124
|
OpenSaasConfig,
|
|
125
|
+
OutputConfig,
|
|
125
126
|
ListConfig,
|
|
126
127
|
ListConfigInput,
|
|
127
128
|
ListAccessControl,
|
|
@@ -135,6 +136,7 @@ export type {
|
|
|
135
136
|
SelectField,
|
|
136
137
|
RelationshipField,
|
|
137
138
|
PrismaRelationResult,
|
|
139
|
+
MultiColumnPrismaResult,
|
|
138
140
|
JsonField,
|
|
139
141
|
VirtualField,
|
|
140
142
|
TypeDescriptor,
|
package/src/config/types.ts
CHANGED
|
@@ -462,12 +462,16 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
|
|
|
462
462
|
* @param fieldName - The name of the field (for generating modifiers)
|
|
463
463
|
* @param provider - Optional database provider ('sqlite', 'postgresql', 'mysql', etc.)
|
|
464
464
|
* @param listName - Optional list name (used for generating enum type names)
|
|
465
|
+
* @param keystoneCompat - Whether Keystone-compat mode is enabled (db.keystoneCompat).
|
|
466
|
+
* When true, non-null text columns without an explicit defaultValue emit
|
|
467
|
+
* `@default("")` to match Keystone 6's implicit empty-string text default.
|
|
465
468
|
* @returns Prisma type string, optional modifiers, and optional enum values
|
|
466
469
|
*/
|
|
467
470
|
getPrismaType?: (
|
|
468
471
|
fieldName: string,
|
|
469
472
|
provider?: string,
|
|
470
473
|
listName?: string,
|
|
474
|
+
keystoneCompat?: boolean,
|
|
471
475
|
) => {
|
|
472
476
|
type: string
|
|
473
477
|
modifiers?: string
|
|
@@ -506,6 +510,72 @@ export type BaseFieldConfig<TTypeInfo extends TypeInfo> = {
|
|
|
506
510
|
*/
|
|
507
511
|
typeOnly?: boolean
|
|
508
512
|
}>
|
|
513
|
+
/**
|
|
514
|
+
* Multi-column Prisma emission.
|
|
515
|
+
*
|
|
516
|
+
* Most scalar fields back a single Prisma column via {@link getPrismaType}.
|
|
517
|
+
* A field that maps onto SEVERAL physical columns (e.g. the storage
|
|
518
|
+
* `image()`/`file()` fields in multi-column / Keystone-parity mode — see
|
|
519
|
+
* ADR-0006) implements this instead: it returns one descriptor per column,
|
|
520
|
+
* each becoming its own line in the generated model. When present, the
|
|
521
|
+
* generator emits these lines and skips the single-column `getPrismaType`
|
|
522
|
+
* path. The field itself owns the column layout — the generator stays a
|
|
523
|
+
* neutral coordinator (no field-type switches), mirroring how relationship
|
|
524
|
+
* fields emit FK + relation lines through `getPrismaRelation`.
|
|
525
|
+
*
|
|
526
|
+
* @param fieldName - The field's config key (used to derive default column names)
|
|
527
|
+
* @returns One descriptor per physical column, or `undefined` to fall back to
|
|
528
|
+
* the single-column `getPrismaType` path.
|
|
529
|
+
*/
|
|
530
|
+
getPrismaColumns?: (fieldName: string) => MultiColumnPrismaResult[] | undefined
|
|
531
|
+
/**
|
|
532
|
+
* The physical Prisma column names this field owns when it spans multiple
|
|
533
|
+
* columns (see {@link getPrismaColumns}). The read path uses this to strip the
|
|
534
|
+
* raw per-part columns from query results so only the assembled logical value
|
|
535
|
+
* (produced by {@link assembleColumns}) is exposed.
|
|
536
|
+
*
|
|
537
|
+
* @param fieldName - The field's config key
|
|
538
|
+
*/
|
|
539
|
+
getColumnNames?: (fieldName: string) => string[]
|
|
540
|
+
/**
|
|
541
|
+
* Assemble the field's logical value from a database row's per-part columns
|
|
542
|
+
* (the read direction of a multi-column field). Pure transform — called by the
|
|
543
|
+
* read pipeline before field visibility. Receives the full row so it can read
|
|
544
|
+
* its sibling columns by name.
|
|
545
|
+
*
|
|
546
|
+
* @param fieldName - The field's config key
|
|
547
|
+
* @param row - The raw database row (contains the per-part columns)
|
|
548
|
+
*/
|
|
549
|
+
assembleColumns?: (fieldName: string, row: Record<string, unknown>) => unknown
|
|
550
|
+
/**
|
|
551
|
+
* Split the field's logical value into per-part columns for writing (the write
|
|
552
|
+
* direction of a multi-column field). Pure transform — called by the write
|
|
553
|
+
* pipeline after `resolveInput`; the returned record is merged into the write
|
|
554
|
+
* payload in place of the single field key.
|
|
555
|
+
*
|
|
556
|
+
* @param fieldName - The field's config key
|
|
557
|
+
* @param value - The resolved logical value (metadata, or `null` to clear)
|
|
558
|
+
*/
|
|
559
|
+
splitColumns?: (fieldName: string, value: unknown) => Record<string, unknown>
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* A single physical column contributed by a multi-column field
|
|
564
|
+
* (see {@link BaseFieldConfig.getPrismaColumns}).
|
|
565
|
+
*/
|
|
566
|
+
export type MultiColumnPrismaResult = {
|
|
567
|
+
/** The Prisma model field name (the property the column is declared as). */
|
|
568
|
+
name: string
|
|
569
|
+
/** The Prisma scalar type, e.g. `'String'` or `'Int'`. */
|
|
570
|
+
type: string
|
|
571
|
+
/**
|
|
572
|
+
* Field modifiers, e.g. `'?'` for nullable. A leading `'?'` attaches to the
|
|
573
|
+
* type; anything after it is treated as trailing attributes (matching the
|
|
574
|
+
* single-column `getPrismaType` modifier convention).
|
|
575
|
+
*/
|
|
576
|
+
modifiers?: string
|
|
577
|
+
/** Physical column name for the `@map` attribute, when it differs from `name`. */
|
|
578
|
+
map?: string
|
|
509
579
|
}
|
|
510
580
|
|
|
511
581
|
export type TextField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig<TTypeInfo> & {
|
|
@@ -587,6 +657,46 @@ export type SelectField<TTypeInfo extends TypeInfo = TypeInfo> = BaseFieldConfig
|
|
|
587
657
|
*/
|
|
588
658
|
type?: 'string' | 'enum'
|
|
589
659
|
map?: string
|
|
660
|
+
/**
|
|
661
|
+
* Force the generated column to be nullable (`?`) even when a `defaultValue`
|
|
662
|
+
* is present. By default a select with a `defaultValue` generates NOT NULL;
|
|
663
|
+
* set this to `true` for an explicit opt-in to a nullable column with a
|
|
664
|
+
* default (e.g. `String? @default("X")` or `<Enum>? @default(X)`), so that
|
|
665
|
+
* a live column containing NULLs migrates without a NOT NULL failure.
|
|
666
|
+
*
|
|
667
|
+
* @default undefined (NOT NULL when a default is present — unchanged behaviour)
|
|
668
|
+
*
|
|
669
|
+
* @example
|
|
670
|
+
* ```typescript
|
|
671
|
+
* // Optional select with a default, but keep the column nullable
|
|
672
|
+
* status: select({
|
|
673
|
+
* options: [{ label: 'Draft', value: 'draft' }],
|
|
674
|
+
* defaultValue: 'draft',
|
|
675
|
+
* db: { isNullable: true },
|
|
676
|
+
* })
|
|
677
|
+
* // Generates: String? @default("draft")
|
|
678
|
+
* ```
|
|
679
|
+
*/
|
|
680
|
+
isNullable?: boolean
|
|
681
|
+
/**
|
|
682
|
+
* Override the generated Prisma enum type name for native-enum selects
|
|
683
|
+
* (only applies when `type: 'enum'`). By default the enum is named
|
|
684
|
+
* `<List><Field>` (e.g. `AccountNoteStatus`); set this to match a live DB
|
|
685
|
+
* enum type whose name differs (e.g. Keystone's `…Type` suffix).
|
|
686
|
+
*
|
|
687
|
+
* The custom name is applied to both the generated `enum` block and every
|
|
688
|
+
* reference to it in the owning model.
|
|
689
|
+
*
|
|
690
|
+
* @example
|
|
691
|
+
* ```typescript
|
|
692
|
+
* status: select({
|
|
693
|
+
* options: [{ label: 'Open', value: 'open' }],
|
|
694
|
+
* db: { type: 'enum', enumName: 'AccountNoteStatusType' },
|
|
695
|
+
* })
|
|
696
|
+
* // Generates: enum AccountNoteStatusType { ... } and the column references it
|
|
697
|
+
* ```
|
|
698
|
+
*/
|
|
699
|
+
enumName?: string
|
|
590
700
|
}
|
|
591
701
|
validation?: {
|
|
592
702
|
isRequired?: boolean
|
|
@@ -1199,6 +1309,65 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
|
|
|
1199
1309
|
operation?: OperationAccess<TTypeInfo['item']>
|
|
1200
1310
|
}
|
|
1201
1311
|
hooks?: Hooks<TTypeInfo['item'], TTypeInfo['inputs']['create'], TTypeInfo['inputs']['update']>
|
|
1312
|
+
/**
|
|
1313
|
+
* Database configuration for this list (model level)
|
|
1314
|
+
*/
|
|
1315
|
+
db?: {
|
|
1316
|
+
/**
|
|
1317
|
+
* Custom database table name.
|
|
1318
|
+
* Adds a `@@map` attribute to the generated Prisma model.
|
|
1319
|
+
*
|
|
1320
|
+
* Useful when the Prisma model name (the list key) must differ from the
|
|
1321
|
+
* physical table name — e.g. adopting an existing better-auth installation
|
|
1322
|
+
* whose tables were created under a different name.
|
|
1323
|
+
*
|
|
1324
|
+
* @example
|
|
1325
|
+
* ```typescript
|
|
1326
|
+
* AuthUser: list({ fields: { ... }, db: { map: 'user' } })
|
|
1327
|
+
* // Generates: model AuthUser { ... @@map("user") }
|
|
1328
|
+
* ```
|
|
1329
|
+
*/
|
|
1330
|
+
map?: string
|
|
1331
|
+
/**
|
|
1332
|
+
* Database schema for this model (Postgres multi-schema).
|
|
1333
|
+
* Adds a `@@schema` attribute to the generated Prisma model.
|
|
1334
|
+
*
|
|
1335
|
+
* Requires the schema to be listed in the datasource `schemas` array (see
|
|
1336
|
+
* {@link DatabaseConfig.schemas}) and the `multiSchema` preview feature,
|
|
1337
|
+
* both of which the generator emits automatically when `db.schemas` is set.
|
|
1338
|
+
*
|
|
1339
|
+
* Useful when adopting an existing installation whose tables live in a
|
|
1340
|
+
* non-`public` schema — e.g. a separate-schema better-auth layout.
|
|
1341
|
+
*
|
|
1342
|
+
* @example
|
|
1343
|
+
* ```typescript
|
|
1344
|
+
* AuthUser: list({ fields: { ... }, db: { schema: 'auth' } })
|
|
1345
|
+
* // Generates: model AuthUser { ... @@schema("auth") }
|
|
1346
|
+
* ```
|
|
1347
|
+
*/
|
|
1348
|
+
schema?: string
|
|
1349
|
+
/**
|
|
1350
|
+
* Per-list override for auto-injected `createdAt`/`updatedAt` timestamp columns.
|
|
1351
|
+
*
|
|
1352
|
+
* Takes precedence over the global `db.timestamps` setting:
|
|
1353
|
+
* - `true` forces auto-timestamps on for this list, even when the global default is off.
|
|
1354
|
+
* - `false` forces them off for this list, even when enabled globally.
|
|
1355
|
+
* - `undefined` (the default) falls back to the global `db.timestamps` setting.
|
|
1356
|
+
*
|
|
1357
|
+
* When timestamps resolve to on but the list already declares its own `createdAt`/
|
|
1358
|
+
* `updatedAt` field, the auto column is skipped for the declared field(s) so Prisma
|
|
1359
|
+
* never sees a duplicate (`P1012`).
|
|
1360
|
+
*
|
|
1361
|
+
* @example Opt a single list out of timestamps even when enabled globally
|
|
1362
|
+
* ```typescript
|
|
1363
|
+
* Production: list({
|
|
1364
|
+
* fields: { name: text() },
|
|
1365
|
+
* db: { timestamps: false },
|
|
1366
|
+
* })
|
|
1367
|
+
* ```
|
|
1368
|
+
*/
|
|
1369
|
+
timestamps?: boolean
|
|
1370
|
+
}
|
|
1202
1371
|
/**
|
|
1203
1372
|
* MCP server configuration for this list
|
|
1204
1373
|
*/
|
|
@@ -1331,6 +1500,86 @@ export type DatabaseConfig = {
|
|
|
1331
1500
|
* ```
|
|
1332
1501
|
*/
|
|
1333
1502
|
joinTableNaming?: 'prisma' | 'keystone'
|
|
1503
|
+
/**
|
|
1504
|
+
* Postgres multi-schema support.
|
|
1505
|
+
*
|
|
1506
|
+
* When set, the generator enables Prisma's `multiSchema` preview feature and
|
|
1507
|
+
* emits the `schemas = [...]` array on the datasource block. Combine with a
|
|
1508
|
+
* per-list `db.schema` (see {@link ListConfig}) to place models in a specific
|
|
1509
|
+
* schema via `@@schema(...)`.
|
|
1510
|
+
*
|
|
1511
|
+
* Only applies to the `postgresql` provider. When unset, the generated schema
|
|
1512
|
+
* is unchanged (single `public` schema, no `@@schema` attributes).
|
|
1513
|
+
*
|
|
1514
|
+
* @example Separate `auth` schema alongside the default `public`
|
|
1515
|
+
* ```typescript
|
|
1516
|
+
* db: {
|
|
1517
|
+
* provider: 'postgresql',
|
|
1518
|
+
* schemas: ['public', 'auth'],
|
|
1519
|
+
* // ...
|
|
1520
|
+
* }
|
|
1521
|
+
* ```
|
|
1522
|
+
*/
|
|
1523
|
+
schemas?: string[]
|
|
1524
|
+
/**
|
|
1525
|
+
* Auto-inject `createdAt`/`updatedAt` timestamp columns into every generated model.
|
|
1526
|
+
*
|
|
1527
|
+
* Default: `false`. The generator does NOT add timestamps automatically — a list
|
|
1528
|
+
* opts in either by declaring the fields itself or by enabling this flag. This matches
|
|
1529
|
+
* Keystone 6, which never adds timestamps automatically, and keeps Keystone → stack
|
|
1530
|
+
* migrations non-destructive (Schema parity). See ADR-0004.
|
|
1531
|
+
*
|
|
1532
|
+
* When `true`, every list receives:
|
|
1533
|
+
* ```prisma
|
|
1534
|
+
* createdAt DateTime @default(now())
|
|
1535
|
+
* updatedAt DateTime @default(now()) @updatedAt
|
|
1536
|
+
* ```
|
|
1537
|
+
*
|
|
1538
|
+
* A per-list `db.timestamps` override takes precedence over this global setting. When
|
|
1539
|
+
* timestamps are enabled but a list already declares its own `createdAt`/`updatedAt`
|
|
1540
|
+
* field, the auto column is skipped for the declared field(s) so Prisma never sees a
|
|
1541
|
+
* duplicate (`P1012`).
|
|
1542
|
+
*
|
|
1543
|
+
* @default false
|
|
1544
|
+
*
|
|
1545
|
+
* @example Re-enable auto-timestamps globally
|
|
1546
|
+
* ```typescript
|
|
1547
|
+
* db: {
|
|
1548
|
+
* provider: 'postgresql',
|
|
1549
|
+
* timestamps: true,
|
|
1550
|
+
* // ... rest of config
|
|
1551
|
+
* }
|
|
1552
|
+
* ```
|
|
1553
|
+
*/
|
|
1554
|
+
timestamps?: boolean
|
|
1555
|
+
/**
|
|
1556
|
+
* Opt into Keystone-compat mode for generated schema defaults.
|
|
1557
|
+
*
|
|
1558
|
+
* Keystone 6 gives every non-null text column an implicit empty-string
|
|
1559
|
+
* default. With `keystoneCompat: true`, the generator mirrors that: any
|
|
1560
|
+
* non-null `text()` column that has no explicit `defaultValue` emits
|
|
1561
|
+
* `@default("")`, so a migrating project reaches Schema parity without
|
|
1562
|
+
* hand-setting `defaultValue: ''` on dozens of columns.
|
|
1563
|
+
*
|
|
1564
|
+
* Stays opt-in (default `false`) because a greenfield project would not want
|
|
1565
|
+
* implicit empty-string text defaults cluttering its schema. The flag never
|
|
1566
|
+
* affects nullable text, fields with an explicit `defaultValue`, or any
|
|
1567
|
+
* non-text field — an explicit `text({ defaultValue: 'x' })` always wins.
|
|
1568
|
+
*
|
|
1569
|
+
* @default false
|
|
1570
|
+
*
|
|
1571
|
+
* @example Reach Schema parity when migrating from Keystone
|
|
1572
|
+
* ```typescript
|
|
1573
|
+
* db: {
|
|
1574
|
+
* provider: 'postgresql',
|
|
1575
|
+
* keystoneCompat: true, // non-null text without a default → @default("")
|
|
1576
|
+
* // ... rest of config
|
|
1577
|
+
* }
|
|
1578
|
+
* ```
|
|
1579
|
+
*
|
|
1580
|
+
* @see ADR-0004 (Keystone-compatible generator defaults)
|
|
1581
|
+
*/
|
|
1582
|
+
keystoneCompat?: boolean
|
|
1334
1583
|
/**
|
|
1335
1584
|
* Optional function to extend or modify the generated Prisma schema
|
|
1336
1585
|
* Receives the generated schema as a string and should return the modified schema
|
|
@@ -1821,6 +2070,34 @@ export type Plugin = {
|
|
|
1821
2070
|
* Main configuration type
|
|
1822
2071
|
* Using interface instead of type to allow module augmentation
|
|
1823
2072
|
*/
|
|
2073
|
+
/**
|
|
2074
|
+
* Configurable generator output locations.
|
|
2075
|
+
*
|
|
2076
|
+
* Lets a project relocate the generated Prisma schema and the `.opensaas`
|
|
2077
|
+
* bundle directory. Paths are interpreted relative to the project root.
|
|
2078
|
+
*
|
|
2079
|
+
* @example
|
|
2080
|
+
* ```typescript
|
|
2081
|
+
* output: {
|
|
2082
|
+
* prismaSchema: 'prisma-opensaas/schema.prisma',
|
|
2083
|
+
* opensaasDir: '.opensaas',
|
|
2084
|
+
* }
|
|
2085
|
+
* ```
|
|
2086
|
+
*/
|
|
2087
|
+
export interface OutputConfig {
|
|
2088
|
+
/**
|
|
2089
|
+
* Path to the generated Prisma schema file.
|
|
2090
|
+
* @default "prisma/schema.prisma"
|
|
2091
|
+
*/
|
|
2092
|
+
prismaSchema?: string
|
|
2093
|
+
/**
|
|
2094
|
+
* Directory for the generated `.opensaas` bundle (types, lists, context,
|
|
2095
|
+
* plugin-types, prisma-extensions, and the patched Prisma client).
|
|
2096
|
+
* @default ".opensaas"
|
|
2097
|
+
*/
|
|
2098
|
+
opensaasDir?: string
|
|
2099
|
+
}
|
|
2100
|
+
|
|
1824
2101
|
export interface OpenSaasConfig {
|
|
1825
2102
|
db: DatabaseConfig
|
|
1826
2103
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Config must accept any list configuration
|
|
@@ -1841,6 +2118,20 @@ export interface OpenSaasConfig {
|
|
|
1841
2118
|
* @default ".opensaas"
|
|
1842
2119
|
*/
|
|
1843
2120
|
opensaasPath?: string
|
|
2121
|
+
/**
|
|
2122
|
+
* Relocate the generator's output so `opensaas generate` can coexist with an
|
|
2123
|
+
* existing `prisma/` directory (e.g. during a Keystone → stack migration).
|
|
2124
|
+
*
|
|
2125
|
+
* Both fields are resolved relative to the project root (the directory the
|
|
2126
|
+
* CLI runs in). When omitted, defaults are unchanged: the schema is written to
|
|
2127
|
+
* `prisma/schema.prisma` and the `.opensaas` bundle to `.opensaas/`.
|
|
2128
|
+
*
|
|
2129
|
+
* The generated files' cross-references follow these locations — `context.ts`
|
|
2130
|
+
* imports the generated types/lists from the resolved `.opensaas` dir, and the
|
|
2131
|
+
* top-level `prisma.config.ts` points at the configured schema path so the
|
|
2132
|
+
* `prisma` CLI keeps working.
|
|
2133
|
+
*/
|
|
2134
|
+
output?: OutputConfig
|
|
1844
2135
|
/**
|
|
1845
2136
|
* Plugins to extend the stack
|
|
1846
2137
|
* Executed in array order (or dependency order if dependencies specified)
|
package/src/context/index.ts
CHANGED
|
@@ -22,6 +22,41 @@ export type ServerActionProps =
|
|
|
22
22
|
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
|
|
23
23
|
| { listKey: string; action: 'delete'; id: string }
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Tracks which (listName, operation) pairs have already warned about an ignored
|
|
27
|
+
* `select` argument, so a misused read op warns once rather than on every call.
|
|
28
|
+
*/
|
|
29
|
+
const selectWarnings = new Set<string>()
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Warn (once per list+operation) when a caller passes a `select` argument to a
|
|
33
|
+
* read op that does not honour it.
|
|
34
|
+
*
|
|
35
|
+
* `context.db` reads never apply Prisma `select` semantics — narrowing is done
|
|
36
|
+
* via `include` or a fragment `query`. The op still runs and returns the full,
|
|
37
|
+
* access-filtered result, so this is a visible no-op rather than an error.
|
|
38
|
+
*
|
|
39
|
+
* Centralised here so every affected read op shares one implementation.
|
|
40
|
+
*/
|
|
41
|
+
function warnIfSelectIgnored(
|
|
42
|
+
args: { select?: unknown } | undefined,
|
|
43
|
+
listName: string,
|
|
44
|
+
operation: string,
|
|
45
|
+
): void {
|
|
46
|
+
if (!args || args.select === undefined) return
|
|
47
|
+
|
|
48
|
+
const key = `${listName}.${operation}`
|
|
49
|
+
if (selectWarnings.has(key)) return
|
|
50
|
+
selectWarnings.add(key)
|
|
51
|
+
|
|
52
|
+
console.warn(
|
|
53
|
+
`[@opensaas/stack-core] \`select\` is ignored by context.db.${getDbKey(listName)}.${operation}() ` +
|
|
54
|
+
`and the full (access-filtered) record is returned. ` +
|
|
55
|
+
`Narrow a read with \`include\` or a fragment \`query\` instead. ` +
|
|
56
|
+
`See https://stack.opensaas.au/docs/core-concepts/queries`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
25
60
|
/**
|
|
26
61
|
* Check if a list is configured as a singleton
|
|
27
62
|
*/
|
|
@@ -373,7 +408,12 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
373
408
|
include?: Record<string, unknown>
|
|
374
409
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
375
410
|
query?: any
|
|
411
|
+
// `select` is not honoured — accepted only so the no-op can be made visible.
|
|
412
|
+
select?: Record<string, unknown>
|
|
376
413
|
}) => {
|
|
414
|
+
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
415
|
+
warnIfSelectIgnored(args, listName, 'findUnique')
|
|
416
|
+
|
|
377
417
|
// Check query access (skip if sudo mode)
|
|
378
418
|
let where: Record<string, unknown> = args.where
|
|
379
419
|
if (!context._isSudo) {
|
|
@@ -471,7 +511,12 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
471
511
|
include?: Record<string, unknown>
|
|
472
512
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
473
513
|
query?: any
|
|
514
|
+
// `select` is not honoured — accepted only so the no-op can be made visible.
|
|
515
|
+
select?: Record<string, unknown>
|
|
474
516
|
}) => {
|
|
517
|
+
// `select` is a visible no-op: warn, then proceed with include/query narrowing.
|
|
518
|
+
warnIfSelectIgnored(args, listName, 'findMany')
|
|
519
|
+
|
|
475
520
|
// Check singleton constraint (throw error instead of silently returning empty)
|
|
476
521
|
if (isSingletonList(listConfig)) {
|
|
477
522
|
throw new ValidationError(
|
package/src/extend.ts
CHANGED
|
@@ -11,4 +11,9 @@
|
|
|
11
11
|
export type { Plugin, PluginContext, GeneratedFiles } from './config/index.js'
|
|
12
12
|
|
|
13
13
|
// Third-party field authoring (implement BaseFieldConfig; see custom-field docs)
|
|
14
|
-
export type {
|
|
14
|
+
export type {
|
|
15
|
+
BaseFieldConfig,
|
|
16
|
+
TypeInfo,
|
|
17
|
+
TypeDescriptor,
|
|
18
|
+
MultiColumnPrismaResult,
|
|
19
|
+
} from './config/index.js'
|