@opensaas/stack-core 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +263 -0
  3. package/CLAUDE.md +50 -0
  4. package/dist/access/access-filter.d.ts +39 -0
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +121 -0
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/field-access.d.ts +1 -0
  9. package/dist/access/field-access.d.ts.map +1 -1
  10. package/dist/access/field-access.js +79 -4
  11. package/dist/access/field-access.js.map +1 -1
  12. package/dist/access/field-access.test.js +213 -0
  13. package/dist/access/field-access.test.js.map +1 -1
  14. package/dist/access/index.d.ts +1 -1
  15. package/dist/access/index.d.ts.map +1 -1
  16. package/dist/access/index.js +1 -1
  17. package/dist/access/index.js.map +1 -1
  18. package/dist/access/types.d.ts +39 -0
  19. package/dist/access/types.d.ts.map +1 -1
  20. package/dist/config/types.d.ts +318 -0
  21. package/dist/config/types.d.ts.map +1 -1
  22. package/dist/context/apply-defaults.d.ts +36 -0
  23. package/dist/context/apply-defaults.d.ts.map +1 -0
  24. package/dist/context/apply-defaults.js +70 -0
  25. package/dist/context/apply-defaults.js.map +1 -0
  26. package/dist/context/hook-pipeline.d.ts.map +1 -1
  27. package/dist/context/hook-pipeline.js +10 -0
  28. package/dist/context/hook-pipeline.js.map +1 -1
  29. package/dist/context/index.d.ts +79 -18
  30. package/dist/context/index.d.ts.map +1 -1
  31. package/dist/context/index.js +194 -39
  32. package/dist/context/index.js.map +1 -1
  33. package/dist/context/nested-operations.d.ts +59 -3
  34. package/dist/context/nested-operations.d.ts.map +1 -1
  35. package/dist/context/nested-operations.js +558 -129
  36. package/dist/context/nested-operations.js.map +1 -1
  37. package/dist/context/transaction-boundary.d.ts +91 -0
  38. package/dist/context/transaction-boundary.d.ts.map +1 -0
  39. package/dist/context/transaction-boundary.js +329 -0
  40. package/dist/context/transaction-boundary.js.map +1 -0
  41. package/dist/context/write-pipeline.d.ts +15 -1
  42. package/dist/context/write-pipeline.d.ts.map +1 -1
  43. package/dist/context/write-pipeline.js +173 -10
  44. package/dist/context/write-pipeline.js.map +1 -1
  45. package/dist/fields/calendar-day.test.d.ts +2 -0
  46. package/dist/fields/calendar-day.test.d.ts.map +1 -0
  47. package/dist/fields/calendar-day.test.js +120 -0
  48. package/dist/fields/calendar-day.test.js.map +1 -0
  49. package/dist/fields/index.d.ts +18 -2
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +93 -17
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/hooks/index.d.ts +116 -0
  54. package/dist/hooks/index.d.ts.map +1 -1
  55. package/dist/hooks/index.js +154 -0
  56. package/dist/hooks/index.js.map +1 -1
  57. package/dist/index.d.ts +1 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js.map +1 -1
  60. package/dist/validation/schema.test.js +222 -1
  61. package/dist/validation/schema.test.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/access/access-filter.ts +156 -0
  64. package/src/access/field-access.test.ts +255 -0
  65. package/src/access/field-access.ts +91 -5
  66. package/src/access/index.ts +1 -1
  67. package/src/access/types.ts +45 -0
  68. package/src/config/types.ts +364 -0
  69. package/src/context/apply-defaults.ts +79 -0
  70. package/src/context/hook-pipeline.ts +11 -0
  71. package/src/context/index.ts +340 -68
  72. package/src/context/nested-operations.ts +976 -143
  73. package/src/context/transaction-boundary.ts +440 -0
  74. package/src/context/write-pipeline.ts +234 -13
  75. package/src/fields/calendar-day.test.ts +140 -0
  76. package/src/fields/index.ts +96 -16
  77. package/src/hooks/index.ts +265 -0
  78. package/src/index.ts +5 -0
  79. package/src/validation/schema.test.ts +266 -1
  80. package/tests/access.test.ts +24 -16
  81. package/tests/apply-defaults.test.ts +119 -0
  82. package/tests/context.test.ts +481 -0
  83. package/tests/default-value-create.test.ts +299 -0
  84. package/tests/field-types.test.ts +17 -3
  85. package/tests/interactive-transaction.test.ts +444 -0
  86. package/tests/nested-access-and-hooks.test.ts +1130 -54
  87. package/tests/nested-operation-registry.test.ts +28 -3
  88. package/tests/nested-write-hooks.test.ts +864 -0
  89. package/tests/sudo.test.ts +7 -3
  90. package/tests/transaction-boundary-hooks.test.ts +465 -0
  91. package/tsconfig.tsbuildinfo +1 -1
@@ -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
+ })
@@ -778,17 +778,31 @@ describe('Field Types', () => {
778
778
  const field = json({ validation: { isRequired: true } })
779
779
  const schema = field.getZodSchema('metadata', 'create')
780
780
 
781
+ // Any present non-null JSON value is accepted, including falsy values
781
782
  expect(schema.safeParse({ key: 'value' }).success).toBe(true)
782
- // JSON field with isRequired still accepts undefined due to z.unknown() behavior
783
- expect(schema.safeParse(undefined).success).toBe(true)
783
+ expect(schema.safeParse([1, 2, 3]).success).toBe(true)
784
+ expect(schema.safeParse(0).success).toBe(true)
785
+ expect(schema.safeParse('').success).toBe(true)
786
+ expect(schema.safeParse(false).success).toBe(true)
787
+ // A required json field must reject undefined on create (issue #597)
788
+ expect(schema.safeParse(undefined).success).toBe(false)
789
+ // A required json field means non-null: reject a present null (issue #604)
790
+ expect(schema.safeParse(null).success).toBe(false)
784
791
  })
785
792
 
786
- test('allows undefined for required field in update mode', () => {
793
+ test('allows undefined but rejects null for required field in update mode', () => {
787
794
  const field = json({ validation: { isRequired: true } })
788
795
  const schema = field.getZodSchema('metadata', 'update')
789
796
 
790
797
  expect(schema.safeParse({ key: 'value' }).success).toBe(true)
798
+ // Omitted/undefined still passes on update (issue #570)
791
799
  expect(schema.safeParse(undefined).success).toBe(true)
800
+ // Present non-null falsy values pass
801
+ expect(schema.safeParse(0).success).toBe(true)
802
+ expect(schema.safeParse('').success).toBe(true)
803
+ expect(schema.safeParse(false).success).toBe(true)
804
+ // A present null is rejected — required json means non-null (issue #604)
805
+ expect(schema.safeParse(null).success).toBe(false)
792
806
  })
793
807
  })
794
808