@opensaas/stack-core 0.25.0 → 0.27.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 +88 -12
- package/CLAUDE.md +50 -0
- package/dist/access/access-filter.d.ts +17 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +39 -0
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +1 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/config/label.d.ts +22 -0
- package/dist/config/label.d.ts.map +1 -0
- package/dist/config/label.js +48 -0
- package/dist/config/label.js.map +1 -0
- package/dist/config/plugin-engine.js.map +1 -1
- package/dist/config/types.d.ts +19 -5
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/apply-defaults.d.ts +36 -0
- package/dist/context/apply-defaults.d.ts.map +1 -0
- package/dist/context/apply-defaults.js +70 -0
- package/dist/context/apply-defaults.js.map +1 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js +10 -0
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +67 -17
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +71 -14
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +6 -0
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/calendar-day.test.js +133 -7
- package/dist/fields/calendar-day.test.js.map +1 -1
- package/dist/fields/format-prisma-default.js.map +1 -1
- package/dist/fields/index.d.ts +7 -3
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +37 -8
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/relationship-options.d.ts +26 -0
- package/dist/query/relationship-options.d.ts.map +1 -0
- package/dist/query/relationship-options.js +43 -0
- package/dist/query/relationship-options.js.map +1 -0
- package/dist/query/relationship-options.test.d.ts +2 -0
- package/dist/query/relationship-options.test.d.ts.map +1 -0
- package/dist/query/relationship-options.test.js +121 -0
- package/dist/query/relationship-options.test.js.map +1 -0
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js.map +1 -1
- package/dist/validation/schema.js.map +1 -1
- package/package.json +6 -5
- package/src/access/access-filter.ts +54 -0
- package/src/access/index.ts +5 -1
- package/src/config/label.ts +58 -0
- package/src/config/types.ts +20 -13
- package/src/context/apply-defaults.ts +79 -0
- package/src/context/hook-pipeline.ts +11 -0
- package/src/context/index.ts +173 -31
- package/src/context/nested-operations.ts +7 -0
- package/src/context/write-pipeline.ts +1 -2
- package/src/fields/calendar-day.test.ts +158 -7
- package/src/fields/index.ts +39 -11
- package/src/hooks/index.ts +1 -2
- package/src/index.ts +16 -0
- package/src/query/relationship-options.test.ts +157 -0
- package/src/query/relationship-options.ts +70 -0
- package/tests/apply-defaults.test.ts +119 -0
- package/tests/context.test.ts +220 -0
- package/tests/default-value-create.test.ts +299 -0
- package/tests/interactive-transaction.test.ts +444 -0
- package/tests/label.test.ts +135 -0
- package/tests/sudo.test.ts +7 -3
- package/tsconfig.tsbuildinfo +1 -1
package/tests/context.test.ts
CHANGED
|
@@ -57,6 +57,7 @@ describe('getContext', () => {
|
|
|
57
57
|
fields: {
|
|
58
58
|
title: { type: 'text' },
|
|
59
59
|
content: { type: 'text' },
|
|
60
|
+
author: { type: 'relationship', ref: 'User.posts' },
|
|
60
61
|
},
|
|
61
62
|
access: {
|
|
62
63
|
operation: {
|
|
@@ -200,6 +201,119 @@ describe('getContext', () => {
|
|
|
200
201
|
error: 'Database connection failed',
|
|
201
202
|
})
|
|
202
203
|
})
|
|
204
|
+
|
|
205
|
+
describe('relationshipOptions', () => {
|
|
206
|
+
it('returns { id, label }[] for the related list, unfiltered/unincluded', async () => {
|
|
207
|
+
mockPrisma.user.findMany.mockResolvedValue([
|
|
208
|
+
{ id: 'u1', name: 'Ada' },
|
|
209
|
+
{ id: 'u2', name: 'Alan' },
|
|
210
|
+
])
|
|
211
|
+
|
|
212
|
+
const context = await getContext(config, mockPrisma, null)
|
|
213
|
+
const result = await context.serverAction({
|
|
214
|
+
listKey: 'Post',
|
|
215
|
+
action: 'relationshipOptions',
|
|
216
|
+
field: 'author',
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
expect(result).toEqual({
|
|
220
|
+
success: true,
|
|
221
|
+
data: [
|
|
222
|
+
{ id: 'u1', label: 'Ada' },
|
|
223
|
+
{ id: 'u2', label: 'Alan' },
|
|
224
|
+
],
|
|
225
|
+
})
|
|
226
|
+
const call = mockPrisma.user.findMany.mock.calls[0][0]
|
|
227
|
+
expect(call.include).toBeUndefined()
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('bounds the query by take', async () => {
|
|
231
|
+
mockPrisma.user.findMany.mockResolvedValue([{ id: 'u1', name: 'Ada' }])
|
|
232
|
+
|
|
233
|
+
const context = await getContext(config, mockPrisma, null)
|
|
234
|
+
await context.serverAction({
|
|
235
|
+
listKey: 'Post',
|
|
236
|
+
action: 'relationshipOptions',
|
|
237
|
+
field: 'author',
|
|
238
|
+
take: 1,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 1 }))
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('unions selectedIds beyond the take/search window', async () => {
|
|
245
|
+
mockPrisma.user.findMany.mockResolvedValueOnce([{ id: 'u1', name: 'Ada' }])
|
|
246
|
+
mockPrisma.user.findMany.mockResolvedValueOnce([{ id: 'u9', name: 'Zed' }])
|
|
247
|
+
|
|
248
|
+
const context = await getContext(config, mockPrisma, null)
|
|
249
|
+
const result = await context.serverAction({
|
|
250
|
+
listKey: 'Post',
|
|
251
|
+
action: 'relationshipOptions',
|
|
252
|
+
field: 'author',
|
|
253
|
+
take: 1,
|
|
254
|
+
selectedIds: ['u9'],
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
expect(result).toEqual({
|
|
258
|
+
success: true,
|
|
259
|
+
data: [
|
|
260
|
+
{ id: 'u1', label: 'Ada' },
|
|
261
|
+
{ id: 'u9', label: 'Zed' },
|
|
262
|
+
],
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('returns [] data when the related list query access is denied', async () => {
|
|
267
|
+
const deniedConfig: OpenSaasConfig = {
|
|
268
|
+
...config,
|
|
269
|
+
lists: {
|
|
270
|
+
...config.lists,
|
|
271
|
+
User: {
|
|
272
|
+
...config.lists.User,
|
|
273
|
+
access: { operation: { query: () => false } },
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
mockPrisma.user.findMany.mockResolvedValue([{ id: 'u1', name: 'Ada' }])
|
|
278
|
+
|
|
279
|
+
const context = await getContext(deniedConfig, mockPrisma, null)
|
|
280
|
+
const result = await context.serverAction({
|
|
281
|
+
listKey: 'Post',
|
|
282
|
+
action: 'relationshipOptions',
|
|
283
|
+
field: 'author',
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
expect(result).toEqual({ success: true, data: [] })
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('returns an error when the field is not a relationship field', async () => {
|
|
290
|
+
const context = await getContext(config, mockPrisma, null)
|
|
291
|
+
const result = await context.serverAction({
|
|
292
|
+
listKey: 'Post',
|
|
293
|
+
action: 'relationshipOptions',
|
|
294
|
+
field: 'title',
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
expect(result).toEqual({
|
|
298
|
+
success: false,
|
|
299
|
+
error: 'Field "title" on list "Post" is not a relationship field',
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('returns an error when the field does not exist', async () => {
|
|
304
|
+
const context = await getContext(config, mockPrisma, null)
|
|
305
|
+
const result = await context.serverAction({
|
|
306
|
+
listKey: 'Post',
|
|
307
|
+
action: 'relationshipOptions',
|
|
308
|
+
field: 'nonexistent',
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
expect(result).toEqual({
|
|
312
|
+
success: false,
|
|
313
|
+
error: 'Field "nonexistent" on list "Post" is not a relationship field',
|
|
314
|
+
})
|
|
315
|
+
})
|
|
316
|
+
})
|
|
203
317
|
})
|
|
204
318
|
|
|
205
319
|
describe('db operations', () => {
|
|
@@ -705,6 +819,112 @@ describe('getContext', () => {
|
|
|
705
819
|
// identical `accessControlledInclude === undefined` branch verified above,
|
|
706
820
|
// so the resolveOutput probe covers all three non-denial cases.
|
|
707
821
|
})
|
|
822
|
+
|
|
823
|
+
// Regression for #628: a virtual field named in `include` used to be
|
|
824
|
+
// forwarded straight through to Prisma, which throws "Unknown field"
|
|
825
|
+
// because virtual fields have no database column. The runtime must
|
|
826
|
+
// strip virtual keys from the include payload while still computing
|
|
827
|
+
// the virtual value (via resolveOutput) and leaving real relationship
|
|
828
|
+
// includes — access-controlled or not — untouched.
|
|
829
|
+
describe('virtual fields named in include no longer reach Prisma (#628)', () => {
|
|
830
|
+
function configWithVirtualDisplayName(): OpenSaasConfig {
|
|
831
|
+
return {
|
|
832
|
+
...relConfig,
|
|
833
|
+
lists: {
|
|
834
|
+
...relConfig.lists,
|
|
835
|
+
Author: {
|
|
836
|
+
...relConfig.lists.Author,
|
|
837
|
+
fields: {
|
|
838
|
+
...relConfig.lists.Author.fields,
|
|
839
|
+
displayName: virtual({
|
|
840
|
+
type: 'string',
|
|
841
|
+
hooks: {
|
|
842
|
+
resolveOutput: ({ item }) => `Author: ${item.name}`,
|
|
843
|
+
},
|
|
844
|
+
}),
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
it('findMany: strips the virtual key from the Prisma include but keeps the real relationship include, and computes the virtual value', async () => {
|
|
852
|
+
const vConfig = configWithVirtualDisplayName()
|
|
853
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo', posts: [] }])
|
|
854
|
+
|
|
855
|
+
const context = await getContext(vConfig, relPrisma, null)
|
|
856
|
+
const result = await context.db.author.findMany({
|
|
857
|
+
include: { displayName: true, posts: true },
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
861
|
+
expect(call.include).not.toHaveProperty('displayName')
|
|
862
|
+
expect(call.include.posts).toBeDefined()
|
|
863
|
+
expect(result[0].displayName).toBe('Author: Jo')
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('findUnique: strips the virtual key from the Prisma include but keeps the real relationship include, and computes the virtual value', async () => {
|
|
867
|
+
const vConfig = configWithVirtualDisplayName()
|
|
868
|
+
relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo', posts: [] })
|
|
869
|
+
|
|
870
|
+
const context = await getContext(vConfig, relPrisma, null)
|
|
871
|
+
const result = await context.db.author.findUnique({
|
|
872
|
+
where: { id: 'a1' },
|
|
873
|
+
include: { displayName: true, posts: true },
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
const call = relPrisma.author.findFirst.mock.calls[0][0]
|
|
877
|
+
expect(call.include).not.toHaveProperty('displayName')
|
|
878
|
+
expect(call.include.posts).toBeDefined()
|
|
879
|
+
expect(result.displayName).toBe('Author: Jo')
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('the virtual value is populated even when omitted from include', async () => {
|
|
883
|
+
const vConfig = configWithVirtualDisplayName()
|
|
884
|
+
relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo' })
|
|
885
|
+
|
|
886
|
+
const context = await getContext(vConfig, relPrisma, null)
|
|
887
|
+
const result = await context.db.author.findUnique({ where: { id: 'a1' } })
|
|
888
|
+
|
|
889
|
+
expect(result.displayName).toBe('Author: Jo')
|
|
890
|
+
})
|
|
891
|
+
|
|
892
|
+
it('sudo: the virtual key is stripped from the Prisma include even though it bypasses access control', async () => {
|
|
893
|
+
const vConfig = configWithVirtualDisplayName()
|
|
894
|
+
relPrisma.author.findMany.mockResolvedValue([{ id: 'a1', name: 'Jo' }])
|
|
895
|
+
|
|
896
|
+
const context = await getContext(vConfig, relPrisma, null).sudo()
|
|
897
|
+
const result = await context.db.author.findMany({
|
|
898
|
+
include: { displayName: true, posts: true },
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
const call = relPrisma.author.findMany.mock.calls[0][0]
|
|
902
|
+
expect(call.include).not.toHaveProperty('displayName')
|
|
903
|
+
// The real relationship is unaffected — sudo still passes it through as-is.
|
|
904
|
+
expect(call.include.posts).toBe(true)
|
|
905
|
+
expect(result[0].displayName).toBe('Author: Jo')
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
it('read access control on the virtual field is still enforced (denied field is omitted)', async () => {
|
|
909
|
+
const vConfig = configWithVirtualDisplayName()
|
|
910
|
+
vConfig.lists.Author.fields.displayName = virtual({
|
|
911
|
+
type: 'string',
|
|
912
|
+
access: { read: () => false },
|
|
913
|
+
hooks: {
|
|
914
|
+
resolveOutput: ({ item }) => `Author: ${item.name}`,
|
|
915
|
+
},
|
|
916
|
+
})
|
|
917
|
+
relPrisma.author.findFirst.mockResolvedValue({ id: 'a1', name: 'Jo' })
|
|
918
|
+
|
|
919
|
+
const context = await getContext(vConfig, relPrisma, null)
|
|
920
|
+
const result = await context.db.author.findUnique({
|
|
921
|
+
where: { id: 'a1' },
|
|
922
|
+
include: { displayName: true },
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
expect(result).not.toHaveProperty('displayName')
|
|
926
|
+
})
|
|
927
|
+
})
|
|
708
928
|
})
|
|
709
929
|
|
|
710
930
|
it('should delegate create to prisma with access control and hooks', async () => {
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { getContext } from '../src/context/index.js'
|
|
3
|
+
import { config, list } from '../src/config/index.js'
|
|
4
|
+
import { text, integer, checkbox, select, relationship } from '../src/fields/index.js'
|
|
5
|
+
import { hookPipeline } from '../src/context/hook-pipeline.js'
|
|
6
|
+
import { ValidationError } from '../src/hooks/index.js'
|
|
7
|
+
import type { ListConfig } from '../src/config/types.js'
|
|
8
|
+
import type { AccessContext } from '../src/access/types.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression tests for #615: a field's `defaultValue` must be applied to omitted
|
|
12
|
+
* inputs BEFORE validation on create (resolve-then-validate, Keystone parity).
|
|
13
|
+
*
|
|
14
|
+
* Before the fix, a required-with-default field (`select`, `text`, `integer`,
|
|
15
|
+
* `checkbox`, …) failed `isRequired` validation on an omitted input because the
|
|
16
|
+
* default was only realised as a Prisma `@default(...)` at DB write time — after
|
|
17
|
+
* validation. These tests cover both the Hook-Pipeline unit surface and a full
|
|
18
|
+
* create through `context.db` (top-level + nested), plus the guard rails:
|
|
19
|
+
* explicit values (incl. explicit null) are preserved and update does not inject.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Minimal AccessContext for driving the Hook Pipeline directly.
|
|
24
|
+
*/
|
|
25
|
+
function makeContext(): AccessContext {
|
|
26
|
+
return {
|
|
27
|
+
session: { userId: 'u1' },
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
prisma: {} as any,
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
db: {} as any,
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
|
+
storage: {} as any,
|
|
34
|
+
plugins: {},
|
|
35
|
+
_isSudo: false,
|
|
36
|
+
_resolveOutputCounter: { depth: 0 },
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A list whose every required field also declares a `defaultValue`, spanning the
|
|
42
|
+
* field types the issue calls out (`select` plus other defaultValue-supporting
|
|
43
|
+
* types).
|
|
44
|
+
*/
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
function makeListConfig(): ListConfig<any> {
|
|
47
|
+
return {
|
|
48
|
+
fields: {
|
|
49
|
+
kind: select({
|
|
50
|
+
validation: { isRequired: true },
|
|
51
|
+
options: [
|
|
52
|
+
{ label: 'Standard', value: 'STANDARD' },
|
|
53
|
+
{ label: 'Trial', value: 'TRIAL' },
|
|
54
|
+
],
|
|
55
|
+
defaultValue: 'STANDARD',
|
|
56
|
+
}),
|
|
57
|
+
label: text({ validation: { isRequired: true }, defaultValue: 'PLACEHOLDER' }),
|
|
58
|
+
count: integer({ validation: { isRequired: true }, defaultValue: 7 }),
|
|
59
|
+
active: checkbox({ defaultValue: true }),
|
|
60
|
+
// A required field WITHOUT a default — to prove validation still fails when
|
|
61
|
+
// there is genuinely nothing to resolve to.
|
|
62
|
+
name: text({ validation: { isRequired: true } }),
|
|
63
|
+
},
|
|
64
|
+
access: { operation: { query: () => true, create: () => true, update: () => true } },
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
66
|
+
} as ListConfig<any>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('#615 Hook Pipeline — defaultValue applied before validation (create)', () => {
|
|
70
|
+
it('fills omitted required-with-default fields (select/text/integer/checkbox) and passes validation', async () => {
|
|
71
|
+
const { resolvedData } = await hookPipeline.run({
|
|
72
|
+
operation: 'create',
|
|
73
|
+
listName: 'Thing',
|
|
74
|
+
listConfig: makeListConfig(),
|
|
75
|
+
// `name` is provided (required, no default); everything else omitted.
|
|
76
|
+
inputData: { name: 'given' },
|
|
77
|
+
item: undefined,
|
|
78
|
+
context: makeContext(),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(resolvedData).toEqual({
|
|
82
|
+
name: 'given',
|
|
83
|
+
kind: 'STANDARD',
|
|
84
|
+
label: 'PLACEHOLDER',
|
|
85
|
+
count: 7,
|
|
86
|
+
active: true,
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('preserves an explicitly-provided value over the default', async () => {
|
|
91
|
+
const { resolvedData } = await hookPipeline.run({
|
|
92
|
+
operation: 'create',
|
|
93
|
+
listName: 'Thing',
|
|
94
|
+
listConfig: makeListConfig(),
|
|
95
|
+
inputData: { name: 'given', kind: 'TRIAL', count: 99, active: false },
|
|
96
|
+
item: undefined,
|
|
97
|
+
context: makeContext(),
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
expect(resolvedData.kind).toBe('TRIAL')
|
|
101
|
+
expect(resolvedData.count).toBe(99)
|
|
102
|
+
expect(resolvedData.active).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('preserves an explicit null and does not overwrite it with the default', async () => {
|
|
106
|
+
// A nullable field with a default: explicit null must survive resolve.
|
|
107
|
+
const listConfig = {
|
|
108
|
+
fields: {
|
|
109
|
+
note: text({ defaultValue: 'DEFAULT_NOTE' }),
|
|
110
|
+
},
|
|
111
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
112
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
113
|
+
} as ListConfig<any>
|
|
114
|
+
|
|
115
|
+
const { resolvedData } = await hookPipeline.run({
|
|
116
|
+
operation: 'create',
|
|
117
|
+
listName: 'Thing',
|
|
118
|
+
listConfig,
|
|
119
|
+
inputData: { note: null },
|
|
120
|
+
item: undefined,
|
|
121
|
+
context: makeContext(),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(resolvedData.note).toBeNull()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('does NOT inject defaults on update (omitted field stays omitted)', async () => {
|
|
128
|
+
const { resolvedData } = await hookPipeline.run({
|
|
129
|
+
operation: 'update',
|
|
130
|
+
listName: 'Thing',
|
|
131
|
+
listConfig: makeListConfig(),
|
|
132
|
+
inputData: { name: 'changed' },
|
|
133
|
+
item: { id: '1', name: 'old', kind: 'TRIAL', label: 'x', count: 1, active: false },
|
|
134
|
+
context: makeContext(),
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// Only the provided field is present; no default was injected on update.
|
|
138
|
+
expect(resolvedData).toEqual({ name: 'changed' })
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('still throws when a required field WITHOUT a default is omitted', async () => {
|
|
142
|
+
await expect(
|
|
143
|
+
hookPipeline.run({
|
|
144
|
+
operation: 'create',
|
|
145
|
+
listName: 'Thing',
|
|
146
|
+
listConfig: makeListConfig(),
|
|
147
|
+
inputData: {}, // `name` is required and has no default
|
|
148
|
+
item: undefined,
|
|
149
|
+
context: makeContext(),
|
|
150
|
+
}),
|
|
151
|
+
).rejects.toBeInstanceOf(ValidationError)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A tiny in-memory Prisma mock supporting interactive transactions and a single
|
|
157
|
+
* nested to-one `create`, mirroring the harness used by the nested-write tests.
|
|
158
|
+
*/
|
|
159
|
+
function createTxPrisma() {
|
|
160
|
+
const tables: Record<string, Map<string, Record<string, unknown>>> = {
|
|
161
|
+
account: new Map(),
|
|
162
|
+
profile: new Map(),
|
|
163
|
+
}
|
|
164
|
+
let idCounter = 0
|
|
165
|
+
const nextId = () => `id-${++idCounter}`
|
|
166
|
+
|
|
167
|
+
function applyNested(
|
|
168
|
+
record: Record<string, unknown>,
|
|
169
|
+
data: Record<string, unknown>,
|
|
170
|
+
): Record<string, unknown> {
|
|
171
|
+
const result = { ...record }
|
|
172
|
+
for (const [key, value] of Object.entries(data)) {
|
|
173
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
174
|
+
const nested = value as Record<string, unknown>
|
|
175
|
+
if (nested.create) {
|
|
176
|
+
const created = doCreate('profile', nested.create as Record<string, unknown>)
|
|
177
|
+
result[`${key}Link`] = created.id
|
|
178
|
+
result[key] = created
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
result[key] = value
|
|
183
|
+
}
|
|
184
|
+
return result
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function doCreate(table: string, data: Record<string, unknown>): Record<string, unknown> {
|
|
188
|
+
const id = (data.id as string) ?? nextId()
|
|
189
|
+
const record = applyNested({ id }, data)
|
|
190
|
+
tables[table].set(id, record)
|
|
191
|
+
return record
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function makeModel(table: string) {
|
|
195
|
+
return {
|
|
196
|
+
findUnique: vi.fn(
|
|
197
|
+
async ({ where }: { where: { id: string } }) => tables[table].get(where.id) ?? null,
|
|
198
|
+
),
|
|
199
|
+
findFirst: vi.fn(async () => tables[table].values().next().value ?? null),
|
|
200
|
+
findMany: vi.fn(async () => Array.from(tables[table].values())),
|
|
201
|
+
count: vi.fn(async () => tables[table].size),
|
|
202
|
+
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => doCreate(table, data)),
|
|
203
|
+
update: vi.fn(),
|
|
204
|
+
delete: vi.fn(),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const client: Record<string, unknown> = {
|
|
209
|
+
account: makeModel('account'),
|
|
210
|
+
profile: makeModel('profile'),
|
|
211
|
+
}
|
|
212
|
+
client.$transaction = async (fn: (tx: unknown) => Promise<unknown>) => fn(client)
|
|
213
|
+
|
|
214
|
+
return { client, tables }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
describe('#615 context.db create — defaultValue resolves through the full pipeline', () => {
|
|
218
|
+
let mock: ReturnType<typeof createTxPrisma>
|
|
219
|
+
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
mock = createTxPrisma()
|
|
222
|
+
vi.clearAllMocks()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('top-level create omitting a required-with-default select succeeds and stores the default', async () => {
|
|
226
|
+
const testConfig = config({
|
|
227
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
228
|
+
lists: {
|
|
229
|
+
Account: list({
|
|
230
|
+
fields: {
|
|
231
|
+
name: text({ validation: { isRequired: true } }),
|
|
232
|
+
kind: select({
|
|
233
|
+
validation: { isRequired: true },
|
|
234
|
+
options: [
|
|
235
|
+
{ label: 'Standard', value: 'STANDARD' },
|
|
236
|
+
{ label: 'Trial', value: 'TRIAL' },
|
|
237
|
+
],
|
|
238
|
+
defaultValue: 'STANDARD',
|
|
239
|
+
}),
|
|
240
|
+
count: integer({ validation: { isRequired: true }, defaultValue: 7 }),
|
|
241
|
+
},
|
|
242
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
243
|
+
}),
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
248
|
+
|
|
249
|
+
const created = await context.db.account.create({ data: { name: 'Acme' } })
|
|
250
|
+
|
|
251
|
+
expect(created).toBeTruthy()
|
|
252
|
+
expect(created?.kind).toBe('STANDARD')
|
|
253
|
+
expect(created?.count).toBe(7)
|
|
254
|
+
// The DB received the resolved default in its `data` payload.
|
|
255
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
256
|
+
const createMock = (mock.client.account as any).create as ReturnType<typeof vi.fn>
|
|
257
|
+
expect(createMock.mock.calls[0][0].data).toMatchObject({ kind: 'STANDARD', count: 7 })
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('nested-relation create omitting a required-with-default select stores the default', async () => {
|
|
261
|
+
const testConfig = config({
|
|
262
|
+
db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
|
|
263
|
+
lists: {
|
|
264
|
+
Account: list({
|
|
265
|
+
fields: {
|
|
266
|
+
name: text({ validation: { isRequired: true } }),
|
|
267
|
+
profile: relationship({ ref: 'Profile.account' }),
|
|
268
|
+
},
|
|
269
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
270
|
+
}),
|
|
271
|
+
Profile: list({
|
|
272
|
+
fields: {
|
|
273
|
+
kind: select({
|
|
274
|
+
validation: { isRequired: true },
|
|
275
|
+
options: [
|
|
276
|
+
{ label: 'Standard', value: 'STANDARD' },
|
|
277
|
+
{ label: 'Trial', value: 'TRIAL' },
|
|
278
|
+
],
|
|
279
|
+
defaultValue: 'STANDARD',
|
|
280
|
+
}),
|
|
281
|
+
account: relationship({ ref: 'Account.profile' }),
|
|
282
|
+
},
|
|
283
|
+
access: { operation: { query: () => true, create: () => true } },
|
|
284
|
+
}),
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const context = getContext(await testConfig, mock.client, { userId: '1' })
|
|
289
|
+
|
|
290
|
+
const created = await context.db.account.create({
|
|
291
|
+
data: { name: 'Acme', profile: { create: {} } },
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
expect(created).toBeTruthy()
|
|
295
|
+
// The nested Profile was created with its default `kind` despite being omitted.
|
|
296
|
+
const profile = mock.tables.profile.values().next().value
|
|
297
|
+
expect(profile?.kind).toBe('STANDARD')
|
|
298
|
+
})
|
|
299
|
+
})
|