@opensaas/stack-core 0.26.0 → 0.27.1

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 (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +54 -12
  3. package/dist/access/access-filter.d.ts +18 -1
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +62 -5
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.d.ts +2 -0
  8. package/dist/access/access-filter.test.d.ts.map +1 -0
  9. package/dist/access/access-filter.test.js +116 -0
  10. package/dist/access/access-filter.test.js.map +1 -0
  11. package/dist/access/field-visibility.js.map +1 -1
  12. package/dist/access/index.d.ts +1 -1
  13. package/dist/access/index.d.ts.map +1 -1
  14. package/dist/access/index.js +1 -1
  15. package/dist/access/index.js.map +1 -1
  16. package/dist/access/multi-column-read-write.test.js.map +1 -1
  17. package/dist/config/label.d.ts +22 -0
  18. package/dist/config/label.d.ts.map +1 -0
  19. package/dist/config/label.js +48 -0
  20. package/dist/config/label.js.map +1 -0
  21. package/dist/config/plugin-engine.js.map +1 -1
  22. package/dist/config/types.d.ts +19 -5
  23. package/dist/config/types.d.ts.map +1 -1
  24. package/dist/context/index.d.ts +7 -0
  25. package/dist/context/index.d.ts.map +1 -1
  26. package/dist/context/index.js +42 -4
  27. package/dist/context/index.js.map +1 -1
  28. package/dist/context/nested-operations.js.map +1 -1
  29. package/dist/context/transaction-boundary.d.ts.map +1 -1
  30. package/dist/context/write-pipeline.d.ts.map +1 -1
  31. package/dist/context/write-pipeline.js.map +1 -1
  32. package/dist/fields/calendar-day.test.js +133 -7
  33. package/dist/fields/calendar-day.test.js.map +1 -1
  34. package/dist/fields/format-prisma-default.js.map +1 -1
  35. package/dist/fields/index.d.ts +7 -3
  36. package/dist/fields/index.d.ts.map +1 -1
  37. package/dist/fields/index.js +37 -8
  38. package/dist/fields/index.js.map +1 -1
  39. package/dist/hooks/index.d.ts.map +1 -1
  40. package/dist/hooks/index.js.map +1 -1
  41. package/dist/index.d.ts +3 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +8 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/query/index.d.ts.map +1 -1
  46. package/dist/query/relationship-options.d.ts +26 -0
  47. package/dist/query/relationship-options.d.ts.map +1 -0
  48. package/dist/query/relationship-options.js +43 -0
  49. package/dist/query/relationship-options.js.map +1 -0
  50. package/dist/query/relationship-options.test.d.ts +2 -0
  51. package/dist/query/relationship-options.test.d.ts.map +1 -0
  52. package/dist/query/relationship-options.test.js +121 -0
  53. package/dist/query/relationship-options.test.js.map +1 -0
  54. package/dist/utils/password.d.ts.map +1 -1
  55. package/dist/utils/password.js.map +1 -1
  56. package/dist/validation/schema.js.map +1 -1
  57. package/package.json +6 -5
  58. package/src/access/access-filter.test.ts +149 -0
  59. package/src/access/access-filter.ts +82 -9
  60. package/src/access/index.ts +5 -1
  61. package/src/config/label.ts +58 -0
  62. package/src/config/types.ts +20 -13
  63. package/src/context/index.ts +57 -5
  64. package/src/context/write-pipeline.ts +1 -2
  65. package/src/fields/calendar-day.test.ts +158 -7
  66. package/src/fields/index.ts +39 -11
  67. package/src/hooks/index.ts +1 -2
  68. package/src/index.ts +11 -0
  69. package/src/query/relationship-options.test.ts +157 -0
  70. package/src/query/relationship-options.ts +70 -0
  71. package/tests/context.test.ts +220 -0
  72. package/tests/label.test.ts +135 -0
  73. package/tsconfig.tsbuildinfo +1 -1
@@ -5,14 +5,7 @@ import type { z } from 'zod'
5
5
  * Field configuration types
6
6
  */
7
7
  export type FieldType =
8
- | 'text'
9
- | 'integer'
10
- | 'checkbox'
11
- | 'timestamp'
12
- | 'password'
13
- | 'select'
14
- | 'relationship'
15
- | string // Allow custom field types from third-party packages
8
+ 'text' | 'integer' | 'checkbox' | 'timestamp' | 'password' | 'select' | 'relationship' | string // Allow custom field types from third-party packages
16
9
 
17
10
  /**
18
11
  * Field-level hook argument types (exported for user annotations)
@@ -1711,11 +1704,11 @@ export type ListConfig<TTypeInfo extends TypeInfo> = {
1711
1704
  /**
1712
1705
  * List-level UI configuration for the admin interface.
1713
1706
  *
1714
- * Mirrors Keystone's `ui` block on a list. Only the list-view defaults
1715
- * (column selection/order and default sort) are supported today; other
1716
- * Keystone concerns (`label`, `labelField`, `description`) are intentionally
1717
- * deferred as they cover different concerns (navigation text and
1718
- * relationship-picker labels rather than list-view defaults).
1707
+ * Mirrors Keystone's `ui` block on a list. List-view defaults (column
1708
+ * selection/order and default sort) and the label field are supported
1709
+ * today; other Keystone concerns (`label`, `description`) are intentionally
1710
+ * deferred as they cover navigation text rather than list-view or
1711
+ * row-labelling defaults.
1719
1712
  */
1720
1713
  export type ListUIConfig = {
1721
1714
  /**
@@ -1723,6 +1716,20 @@ export type ListUIConfig = {
1723
1716
  * Keystone's `ui.listView`.
1724
1717
  */
1725
1718
  listView?: ListViewUIConfig
1719
+ /**
1720
+ * The field used to represent a row as a single label — in relationship
1721
+ * cells, dropdown options, and page headings. Must reference a declared,
1722
+ * non-relationship field on this list.
1723
+ *
1724
+ * When omitted, resolves via `getLabelFieldName`'s fallback order: `name`
1725
+ * → `title` → `id` (first field that exists on the list).
1726
+ *
1727
+ * @example
1728
+ * ```typescript
1729
+ * ui: { labelField: 'email' }
1730
+ * ```
1731
+ */
1732
+ labelField?: string
1726
1733
  }
1727
1734
 
1728
1735
  /**
@@ -6,11 +6,13 @@ import {
6
6
  filterReadableFields,
7
7
  buildIncludeWithAccessControl,
8
8
  mergeIncludeWithAccessControl,
9
+ stripVirtualFieldsFromInclude,
9
10
  } from '../access/index.js'
10
11
  import { ValidationError, DatabaseError } from '../hooks/index.js'
11
12
  import { getDbKey } from '../lib/case-utils.js'
12
13
  import type { PrismaClientLike } from '../access/types.js'
13
14
  import { buildInclude, pickFields, isFragment } from '../query/index.js'
15
+ import { getRelationshipOptions } from '../query/relationship-options.js'
14
16
  import {
15
17
  runWritePipeline,
16
18
  createWriteStrategy,
@@ -22,6 +24,14 @@ export type ServerActionProps =
22
24
  | { listKey: string; action: 'create'; data: Record<string, unknown> }
23
25
  | { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
24
26
  | { listKey: string; action: 'delete'; id: string }
27
+ | {
28
+ listKey: string
29
+ action: 'relationshipOptions'
30
+ field: string
31
+ search?: string
32
+ take?: number
33
+ selectedIds?: string[]
34
+ }
25
35
 
26
36
  /**
27
37
  * Tracks which (listName, operation) pairs have already warned about an ignored
@@ -239,11 +249,7 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
239
249
  * PostgreSQL — required for the concurrency-sensitive capacity-gate pattern).
240
250
  */
241
251
  export type TransactionIsolationLevel =
242
- | 'ReadUncommitted'
243
- | 'ReadCommitted'
244
- | 'RepeatableRead'
245
- | 'Serializable'
246
- | 'Snapshot'
252
+ 'ReadUncommitted' | 'ReadCommitted' | 'RepeatableRead' | 'Serializable' | 'Snapshot'
247
253
 
248
254
  /**
249
255
  * Options for {@link StackContext.transaction}, forwarded verbatim to the
@@ -421,6 +427,26 @@ export function getContext<
421
427
  }
422
428
 
423
429
  try {
430
+ if (props.action === 'relationshipOptions') {
431
+ const fieldConfig = listConfig.fields[props.field] as
432
+ { type?: string; ref?: string } | undefined
433
+ if (!fieldConfig || fieldConfig.type !== 'relationship' || !fieldConfig.ref) {
434
+ return {
435
+ success: false,
436
+ error: `Field "${props.field}" on list "${props.listKey}" is not a relationship field`,
437
+ }
438
+ }
439
+
440
+ const relatedListKey = fieldConfig.ref.split('.')[0]
441
+ const options = await getRelationshipOptions(context, config, relatedListKey, {
442
+ search: props.search,
443
+ take: props.take,
444
+ selectedIds: props.selectedIds,
445
+ })
446
+
447
+ return { success: true, data: options }
448
+ }
449
+
424
450
  let result: unknown = null
425
451
 
426
452
  if (props.action === 'create') {
@@ -673,6 +699,10 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
673
699
  context,
674
700
  },
675
701
  config,
702
+ 0,
703
+ // Seed the cycle guard with the root list so a relationship cycle back
704
+ // to it (self-referential or longer) stops re-descending.
705
+ [listName],
676
706
  )
677
707
  // MERGE (not replace) a caller-supplied include with the access-controlled
678
708
  // include: the caller selects WHICH relations to fetch, access control
@@ -688,6 +718,13 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
688
718
  : accessControlledInclude
689
719
  }
690
720
 
721
+ // Virtual fields have no database column. Whichever path produced
722
+ // `include` (fragment, access-controlled merge, or sudo passthrough), a
723
+ // virtual key must never reach Prisma — it would throw "Unknown field"
724
+ // (#628). The virtual value is still computed unconditionally below by
725
+ // `filterReadableFields`, independent of what was requested here.
726
+ include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
727
+
691
728
  // Execute query with optimized includes
692
729
  // Access Prisma model dynamically - required because model names are generated at runtime
693
730
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -796,6 +833,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
796
833
  context,
797
834
  },
798
835
  config,
836
+ 0,
837
+ // Seed the cycle guard with the root list so a relationship cycle back
838
+ // to it (self-referential or longer) stops re-descending.
839
+ [listName],
799
840
  )
800
841
  // MERGE (not replace) a caller-supplied include with the access-controlled
801
842
  // include: the caller selects WHICH relations to fetch, access control
@@ -811,6 +852,13 @@ function createFindMany<TPrisma extends PrismaClientLike>(
811
852
  : accessControlledInclude
812
853
  }
813
854
 
855
+ // Virtual fields have no database column. Whichever path produced
856
+ // `include` (fragment, access-controlled merge, or sudo passthrough), a
857
+ // virtual key must never reach Prisma — it would throw "Unknown field"
858
+ // (#628). The virtual value is still computed unconditionally below by
859
+ // `filterReadableFields`, independent of what was requested here.
860
+ include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
861
+
814
862
  // Execute query with optimized includes
815
863
  // Access Prisma model dynamically - required because model names are generated at runtime
816
864
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1101,6 +1149,10 @@ function createGet<TPrisma extends PrismaClientLike>(
1101
1149
  context,
1102
1150
  },
1103
1151
  config,
1152
+ 0,
1153
+ // Seed the cycle guard with the root list so a relationship cycle back
1154
+ // to it (self-referential or longer) stops re-descending.
1155
+ [listName],
1104
1156
  )
1105
1157
 
1106
1158
  // Try to find the record
@@ -53,8 +53,7 @@ export type WriteOperation = 'create' | 'update' | 'delete'
53
53
  * any input phases, before-hooks, or the DB call.
54
54
  */
55
55
  export type TargetResolution =
56
- | { status: 'ok'; originalItem: Record<string, unknown> | undefined }
57
- | { status: 'denied' }
56
+ { status: 'ok'; originalItem: Record<string, unknown> | undefined } | { status: 'denied' }
58
57
 
59
58
  /**
60
59
  * Minimal dynamic Prisma model surface used by the write pipeline. Model names
@@ -1,7 +1,9 @@
1
- import { describe, it, expect, expectTypeOf } from 'vitest'
1
+ import { describe, it, expect, expectTypeOf, vi } from 'vitest'
2
2
  import { calendarDay } from './index.js'
3
3
  import { generateZodSchema, validateWithZod } from '../validation/schema.js'
4
+ import { getContext } from '../context/index.js'
4
5
  import type { FieldConfig } from '../config/types.js'
6
+ import type { OpenSaasConfig } from '../config/types.js'
5
7
 
6
8
  /**
7
9
  * calendarDay is a YYYY-MM-DD string end-to-end (Keystone's CalendarDay
@@ -54,7 +56,7 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
54
56
  })
55
57
  })
56
58
 
57
- describe('write validation (string-only)', () => {
59
+ describe('write validation (YYYY-MM-DD string, or a Date post-resolveInput)', () => {
58
60
  const fields: Record<string, FieldConfig> = {
59
61
  startsOn: calendarDay({ validation: { isRequired: true } }),
60
62
  }
@@ -73,15 +75,18 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
73
75
  }
74
76
  })
75
77
 
76
- it('rejects a Date instance at runtime (not a string)', () => {
77
- // A typed caller cannot reach here (input type is `string`), but the
78
- // validator is string-only as a runtime backstop.
78
+ it('accepts a Date instance (the shape resolveInput produces from a valid string)', () => {
79
+ // The write pipeline runs field `resolveInput` BEFORE this schema, and
80
+ // calendarDay's resolveInput turns a valid YYYY-MM-DD string into a UTC
81
+ // Date (see #621) so Prisma's `@db.Date` write validator accepts it. So
82
+ // by the time this schema runs, a successful write reaches it as a
83
+ // Date, not the original string — the schema must accept both shapes.
79
84
  const result = validateWithZod(
80
- { startsOn: new Date('2025-01-15') } as unknown as Record<string, unknown>,
85
+ { startsOn: new Date('2025-01-15T00:00:00.000Z') } as unknown as Record<string, unknown>,
81
86
  fields,
82
87
  'create',
83
88
  )
84
- expect(result.success).toBe(false)
89
+ expect(result.success).toBe(true)
85
90
  })
86
91
 
87
92
  it('zod schema for the field validates the YYYY-MM-DD shape', () => {
@@ -91,6 +96,62 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
91
96
  })
92
97
  })
93
98
 
99
+ describe('write transform (resolveInput coerces YYYY-MM-DD string to a UTC Date, #621)', () => {
100
+ // The write pipeline calls fieldConfig.hooks.resolveInput({ resolvedData,
101
+ // fieldKey, ... }) BEFORE zod validation runs. We exercise that hook
102
+ // directly with the value shapes a caller (or an upstream list-level
103
+ // resolveInput) can produce.
104
+ function writeValue(value: unknown): unknown {
105
+ const field = calendarDay()
106
+ const hook = field.hooks?.resolveInput
107
+ if (!hook) throw new Error('calendarDay must define a resolveInput hook')
108
+ return (
109
+ hook as unknown as (args: {
110
+ resolvedData: Record<string, unknown>
111
+ fieldKey: string
112
+ }) => unknown
113
+ )({ resolvedData: { startsOn: value }, fieldKey: 'startsOn' })
114
+ }
115
+
116
+ it('converts a YYYY-MM-DD string to a UTC-midnight Date', () => {
117
+ const result = writeValue('2025-01-15') as Date
118
+ expect(result).toBeInstanceOf(Date)
119
+ expect(result.toISOString()).toBe('2025-01-15T00:00:00.000Z')
120
+ })
121
+
122
+ it('passes an already-Date value through unchanged', () => {
123
+ const date = new Date('2025-06-01T00:00:00.000Z')
124
+ expect(writeValue(date)).toBe(date)
125
+ })
126
+
127
+ it('passes null/undefined through unchanged (so isRequired can still reject a missing value)', () => {
128
+ expect(writeValue(null)).toBeNull()
129
+ expect(writeValue(undefined)).toBeUndefined()
130
+ })
131
+
132
+ it('leaves a malformed string untouched so zod still rejects it with a clear message', () => {
133
+ expect(writeValue('15/01/2025')).toBe('15/01/2025')
134
+ })
135
+
136
+ it('reads resolvedData[fieldKey], not inputData — survives a list-level resolveInput default', () => {
137
+ // A list-level resolveInput can inject a default for an omitted key
138
+ // into resolvedData before field resolveInput runs. Reading
139
+ // resolvedData (not the original inputData) here means that injected
140
+ // default is what gets coerced, instead of being read as `undefined`
141
+ // and overwriting the injected default with null.
142
+ const field = calendarDay()
143
+ const hook = field.hooks?.resolveInput as unknown as (args: {
144
+ resolvedData: Record<string, unknown>
145
+ fieldKey: string
146
+ }) => unknown
147
+ const result = hook({
148
+ resolvedData: { startsOn: '2025-03-20' }, // injected by a list-level hook
149
+ fieldKey: 'startsOn',
150
+ }) as Date
151
+ expect(result.toISOString()).toBe('2025-03-20T00:00:00.000Z')
152
+ })
153
+ })
154
+
94
155
  describe('read transform (resolveOutput returns a YYYY-MM-DD string)', () => {
95
156
  // The read pipeline calls fieldConfig.hooks.resolveOutput({ value, ... }).
96
157
  // We exercise that hook directly with the value shapes Prisma can return.
@@ -137,4 +198,94 @@ describe('calendarDay field (YYYY-MM-DD string end-to-end)', () => {
137
198
  expect(hook({ value: '2025-01-15' })).toBe('custom:2025-01-15')
138
199
  })
139
200
  })
201
+
202
+ describe('end-to-end via context.db.*.create/update (#621 repro)', () => {
203
+ // Prisma 7's client validator rejects a bare YYYY-MM-DD string for a
204
+ // `@db.Date` column. Assert the value actually forwarded to the Prisma
205
+ // client is a Date, not the string the caller passed in.
206
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock Prisma client
207
+ let mockPrisma: any
208
+
209
+ function buildConfig(): OpenSaasConfig {
210
+ mockPrisma = {
211
+ event: {
212
+ findFirst: vi.fn(),
213
+ findUnique: vi.fn(),
214
+ findMany: vi.fn(),
215
+ create: vi.fn(),
216
+ update: vi.fn(),
217
+ delete: vi.fn(),
218
+ count: vi.fn(),
219
+ },
220
+ }
221
+ return {
222
+ db: {
223
+ provider: 'postgresql',
224
+ prismaClientConstructor: (PrismaClient) => new PrismaClient(),
225
+ },
226
+ lists: {
227
+ Event: {
228
+ fields: {
229
+ startsOn: calendarDay({ validation: { isRequired: true } }),
230
+ },
231
+ access: {
232
+ operation: {
233
+ query: () => true,
234
+ create: () => true,
235
+ update: () => true,
236
+ delete: () => true,
237
+ },
238
+ },
239
+ },
240
+ },
241
+ }
242
+ }
243
+
244
+ it('create: a YYYY-MM-DD string reaches Prisma as a UTC-midnight Date', async () => {
245
+ const config = buildConfig()
246
+ mockPrisma.event.create.mockResolvedValue({
247
+ id: '1',
248
+ startsOn: new Date('2025-01-15T00:00:00.000Z'),
249
+ })
250
+ const context = await getContext(config, mockPrisma, null)
251
+
252
+ await context.db.event.create({ data: { startsOn: '2025-01-15' } })
253
+
254
+ expect(mockPrisma.event.create).toHaveBeenCalledTimes(1)
255
+ const callArgs = mockPrisma.event.create.mock.calls[0][0]
256
+ expect(callArgs.data.startsOn).toBeInstanceOf(Date)
257
+ expect((callArgs.data.startsOn as Date).toISOString()).toBe('2025-01-15T00:00:00.000Z')
258
+ })
259
+
260
+ it('update: a YYYY-MM-DD string reaches Prisma as a UTC-midnight Date', async () => {
261
+ const config = buildConfig()
262
+ const existing = { id: '1', startsOn: new Date('2025-01-15T00:00:00.000Z') }
263
+ mockPrisma.event.findUnique.mockResolvedValue(existing)
264
+ mockPrisma.event.update.mockResolvedValue({
265
+ ...existing,
266
+ startsOn: new Date('2025-02-20T00:00:00.000Z'),
267
+ })
268
+ const context = await getContext(config, mockPrisma, null)
269
+
270
+ await context.db.event.update({ where: { id: '1' }, data: { startsOn: '2025-02-20' } })
271
+
272
+ expect(mockPrisma.event.update).toHaveBeenCalledTimes(1)
273
+ const callArgs = mockPrisma.event.update.mock.calls[0][0]
274
+ expect(callArgs.data.startsOn).toBeInstanceOf(Date)
275
+ expect((callArgs.data.startsOn as Date).toISOString()).toBe('2025-02-20T00:00:00.000Z')
276
+ })
277
+
278
+ it('the read result is still normalised back to a YYYY-MM-DD string', async () => {
279
+ const config = buildConfig()
280
+ mockPrisma.event.create.mockResolvedValue({
281
+ id: '1',
282
+ startsOn: new Date('2025-01-15T00:00:00.000Z'),
283
+ })
284
+ const context = await getContext(config, mockPrisma, null)
285
+
286
+ const result = await context.db.event.create({ data: { startsOn: '2025-01-15' } })
287
+
288
+ expect(result?.startsOn).toBe('2025-01-15')
289
+ })
290
+ })
140
291
  })
@@ -523,9 +523,13 @@ export function timestamp<
523
523
  * - Stores date values only (no time component)
524
524
  * - PostgreSQL/MySQL: Uses native DATE type via @db.Date
525
525
  * - SQLite: Uses String representation
526
- * - **Writes:** accept only a `YYYY-MM-DD` string; a malformed string or a
527
- * `Date` is rejected at runtime by validation (a `ValidationError`). Genuine
528
- * compile-time rejection at the `context.db` call site is tracked in #599.
526
+ * - **Writes:** pass a `YYYY-MM-DD` string (the declared type). A
527
+ * `resolveInput` hook converts a valid string to a UTC-midnight `Date`
528
+ * before validation, since Prisma 7's client validator rejects a bare date
529
+ * string for a `@db.Date` column (#621); a `Date` is also accepted
530
+ * directly. A malformed string is rejected at runtime by validation (a
531
+ * `ValidationError`). Genuine compile-time rejection of a `Date` at the
532
+ * `context.db` call site is tracked in #599.
529
533
  * - **Reads:** always return a `YYYY-MM-DD` string. Even though the underlying
530
534
  * `@db.Date` column hands Prisma a `Date`, a `resolveOutput` transform
531
535
  * normalises it back to a `YYYY-MM-DD` string so the runtime value matches
@@ -573,6 +577,17 @@ export function calendarDay<
573
577
  return {
574
578
  type: 'calendarDay',
575
579
  ...options,
580
+ // Writes: the write pipeline runs field resolveInput BEFORE zod
581
+ // validation (Hook Pipeline: field resolveInput → built-in field rules),
582
+ // so this is the only point a YYYY-MM-DD string can be turned into
583
+ // something Prisma's `@db.Date` write validator accepts — Prisma 7
584
+ // rejects a bare date string there (#621). Convert a valid string to a
585
+ // UTC-midnight Date; leave anything else (a Date already, null/undefined,
586
+ // or a malformed string) untouched so the zod schema below still rejects
587
+ // malformed input with a clear message. Reads resolvedData[fieldKey]
588
+ // (not raw inputData) so a list-level resolveInput that injects a default
589
+ // for an omitted key is still coerced instead of being overwritten.
590
+ //
576
591
  // Reads: the underlying @db.Date column hands Prisma a Date (or a TEXT
577
592
  // string under the SQLite fallback). Normalise to a YYYY-MM-DD string so the
578
593
  // runtime value matches the declared `string` type. UTC components are used
@@ -580,6 +595,15 @@ export function calendarDay<
580
595
  // Cast hooks to any since field builders are generic and can't know the
581
596
  // specific TFieldKey (same pattern as password()).
582
597
  hooks: {
598
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
599
+ resolveInput: ({ resolvedData, fieldKey }: { resolvedData: any; fieldKey: string }) => {
600
+ const value = resolvedData?.[fieldKey]
601
+ if (value == null || value instanceof Date) return value
602
+ if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
603
+ return new Date(`${value}T00:00:00.000Z`)
604
+ }
605
+ return value
606
+ },
583
607
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field builder hooks must be generic
584
608
  resolveOutput: ({ value }: { value: any }) => formatCalendarDay(value),
585
609
  // Merge with user-provided hooks if any
@@ -590,15 +614,19 @@ export function calendarDay<
590
614
  const validation = options?.validation
591
615
  const isRequired = validation?.isRequired
592
616
 
593
- // Accept ISO8601 date strings (YYYY-MM-DD)
594
- const baseSchema = z.string({
595
- message: `${formatFieldName(fieldName)} must be a valid date in ISO8601 format (YYYY-MM-DD)`,
596
- })
617
+ // Accept ISO8601 date strings (YYYY-MM-DD) in the shape a caller passes,
618
+ // or a `Date` — the shape resolveInput above turns a valid string into
619
+ // before this schema runs. Malformed strings fall through resolveInput
620
+ // untouched and still fail the regex here with a clear message.
621
+ const stringSchema = z
622
+ .string({
623
+ message: `${formatFieldName(fieldName)} must be a valid date in ISO8601 format (YYYY-MM-DD)`,
624
+ })
625
+ .regex(/^\d{4}-\d{2}-\d{2}$/, {
626
+ message: `${formatFieldName(fieldName)} must be in YYYY-MM-DD format`,
627
+ })
597
628
 
598
- // Validate ISO8601 date format (YYYY-MM-DD)
599
- const dateSchema = baseSchema.regex(/^\d{4}-\d{2}-\d{2}$/, {
600
- message: `${formatFieldName(fieldName)} must be in YYYY-MM-DD format`,
601
- })
629
+ const dateSchema = z.union([stringSchema, z.date()])
602
630
 
603
631
  if (isRequired && operation === 'create') {
604
632
  return dateSchema
@@ -386,8 +386,7 @@ export async function executeFieldBeforeTransactionHooks(
386
386
  * so hooks can compensate.
387
387
  */
388
388
  export type TransactionOutcome =
389
- | { status: 'committed'; item: Record<string, unknown> }
390
- | { status: 'rolled-back'; error: unknown }
389
+ { status: 'committed'; item: Record<string, unknown> } | { status: 'rolled-back'; error: unknown }
391
390
 
392
391
  /**
393
392
  * Execute field-level afterTransaction hooks (#590 / ADR-0010).
package/src/index.ts CHANGED
@@ -43,6 +43,11 @@ export type {
43
43
  // Naming utilities (documented public helpers; used for URLs and db keys)
44
44
  export { getDbKey, getUrlKey, getListKeyFromUrl } from './lib/case-utils.js'
45
45
 
46
+ // Label seam — resolves the field that represents a row as a single label
47
+ // (projection) and reads it off a row (render). Used by the admin UI for
48
+ // relationship cells, dropdown options, and page headings.
49
+ export { getLabelFieldName, getItemLabel } from './config/label.js'
50
+
46
51
  // Validation error surfaced by write operations
47
52
  export { ValidationError } from './hooks/index.js'
48
53
 
@@ -61,3 +66,9 @@ export type { FieldConfigValidationError } from './validation/field-config.js'
61
66
  // root surface — those live on '@opensaas/stack-core/internal'.
62
67
  export { defineFragment, runQuery, runQueryOne } from './query/index.js'
63
68
  export type { ResultOf, RelationSelector, QueryArgs } from './query/index.js'
69
+
70
+ // Relationship-options read primitive — bounded, projected fetch for
71
+ // relationship editors. Backs the `relationshipOptions` context.serverAction
72
+ // op; also callable directly wherever a full context is already in hand.
73
+ export { getRelationshipOptions } from './query/relationship-options.js'
74
+ export type { RelationshipOption, RelationshipOptionsArgs } from './query/relationship-options.js'
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { getRelationshipOptions } from './relationship-options.js'
3
+ import type { QueryRunnerContext } from './index.js'
4
+ import type { OpenSaasConfig } from '../config/types.js'
5
+
6
+ function makeDelegate(rows: Array<Record<string, unknown>>) {
7
+ return {
8
+ findMany: vi.fn(async (_args?: unknown) => rows),
9
+ findFirst: vi.fn(async (_args?: unknown) => rows[0] ?? null),
10
+ }
11
+ }
12
+
13
+ function makeContext(
14
+ delegates: Record<string, ReturnType<typeof makeDelegate>>,
15
+ ): QueryRunnerContext {
16
+ return { db: delegates }
17
+ }
18
+
19
+ const authors = [
20
+ { id: 'a1', name: 'Ada Lovelace' },
21
+ { id: 'a2', name: 'Alan Turing' },
22
+ { id: 'a3', name: 'Grace Hopper' },
23
+ ]
24
+
25
+ function makeConfig(): OpenSaasConfig {
26
+ return {
27
+ db: { provider: 'sqlite', url: 'file:./test.db' },
28
+ lists: {
29
+ Author: {
30
+ fields: {
31
+ name: { type: 'text' },
32
+ },
33
+ access: { operation: { query: () => true } },
34
+ },
35
+ NumericLabel: {
36
+ fields: {
37
+ rank: { type: 'integer' },
38
+ },
39
+ ui: { labelField: 'rank' },
40
+ access: { operation: { query: () => true } },
41
+ },
42
+ },
43
+ } as unknown as OpenSaasConfig
44
+ }
45
+
46
+ describe('getRelationshipOptions', () => {
47
+ it('returns { id, label }[] via a scalar-only fragment with no nested include', async () => {
48
+ const delegate = makeDelegate(authors)
49
+ const context = makeContext({ author: delegate })
50
+ const config = makeConfig()
51
+
52
+ const result = await getRelationshipOptions(context, config, 'Author', {})
53
+
54
+ expect(result).toEqual([
55
+ { id: 'a1', label: 'Ada Lovelace' },
56
+ { id: 'a2', label: 'Alan Turing' },
57
+ { id: 'a3', label: 'Grace Hopper' },
58
+ ])
59
+
60
+ const call = delegate.findMany.mock.calls[0][0] as Record<string, unknown>
61
+ expect(call.include).toBeUndefined()
62
+ })
63
+
64
+ it('bounds the result by take', async () => {
65
+ const delegate = makeDelegate(authors.slice(0, 2))
66
+ const context = makeContext({ author: delegate })
67
+ const config = makeConfig()
68
+
69
+ await getRelationshipOptions(context, config, 'Author', { take: 2 })
70
+
71
+ expect(delegate.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 2 }))
72
+ })
73
+
74
+ it('orders by the label field ascending and filters via contains on a text label field', async () => {
75
+ const delegate = makeDelegate([authors[0]])
76
+ const context = makeContext({ author: delegate })
77
+ const config = makeConfig()
78
+
79
+ await getRelationshipOptions(context, config, 'Author', { search: 'Ada' })
80
+
81
+ expect(delegate.findMany).toHaveBeenCalledWith(
82
+ expect.objectContaining({
83
+ where: { name: { contains: 'Ada' } },
84
+ orderBy: { name: 'asc' },
85
+ }),
86
+ )
87
+ })
88
+
89
+ it('does not filter (first-N) when the label field is not a text field', async () => {
90
+ const rows = [{ id: 'n1', rank: 1 }]
91
+ const delegate = makeDelegate(rows)
92
+ const context = makeContext({ numericLabel: delegate })
93
+ const config = makeConfig()
94
+
95
+ await getRelationshipOptions(context, config, 'NumericLabel', { search: '1' })
96
+
97
+ const call = delegate.findMany.mock.calls[0][0] as Record<string, unknown>
98
+ expect(call.where).toBeUndefined()
99
+ expect(call.orderBy).toEqual({ rank: 'asc' })
100
+ })
101
+
102
+ it('unions currently-selected ids even when beyond take / not matching search', async () => {
103
+ // The bounded/search-scoped query only returns a1 (mimicking take:1 + search).
104
+ const primaryDelegate = makeDelegate([authors[0]])
105
+ const context = makeContext({ author: primaryDelegate })
106
+ const config = makeConfig()
107
+
108
+ // The selected-ids query (a separate findMany call) resolves a3, which fell
109
+ // outside the primary window.
110
+ primaryDelegate.findMany.mockImplementationOnce(async () => [authors[0]])
111
+ primaryDelegate.findMany.mockImplementationOnce(async () => [authors[2]])
112
+
113
+ const result = await getRelationshipOptions(context, config, 'Author', {
114
+ take: 1,
115
+ selectedIds: ['a3'],
116
+ })
117
+
118
+ expect(result).toEqual([
119
+ { id: 'a1', label: 'Ada Lovelace' },
120
+ { id: 'a3', label: 'Grace Hopper' },
121
+ ])
122
+
123
+ const selectedCall = primaryDelegate.findMany.mock.calls[1][0] as Record<string, unknown>
124
+ expect(selectedCall.where).toEqual({ id: { in: ['a3'] } })
125
+ })
126
+
127
+ it('does not re-query when the selected id is already within the primary window', async () => {
128
+ const delegate = makeDelegate([authors[0]])
129
+ const context = makeContext({ author: delegate })
130
+ const config = makeConfig()
131
+
132
+ await getRelationshipOptions(context, config, 'Author', { selectedIds: ['a1'] })
133
+
134
+ expect(delegate.findMany).toHaveBeenCalledTimes(1)
135
+ })
136
+
137
+ it('returns [] when the related list query access is denied (findMany returns [])', async () => {
138
+ const delegate = makeDelegate([])
139
+ const context = makeContext({ author: delegate })
140
+ const config = makeConfig()
141
+
142
+ const result = await getRelationshipOptions(context, config, 'Author', {
143
+ selectedIds: ['a1'],
144
+ })
145
+
146
+ expect(result).toEqual([])
147
+ })
148
+
149
+ it('returns [] when the related list does not exist in config', async () => {
150
+ const context = makeContext({})
151
+ const config = makeConfig()
152
+
153
+ const result = await getRelationshipOptions(context, config, 'Missing', {})
154
+
155
+ expect(result).toEqual([])
156
+ })
157
+ })