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