@opensaas/stack-core 0.39.2 → 0.40.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 (130) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +150 -0
  3. package/CLAUDE.md +51 -0
  4. package/dist/access/access-filter.d.ts +83 -10
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +139 -18
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +224 -12
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/engine.d.ts +43 -1
  11. package/dist/access/engine.d.ts.map +1 -1
  12. package/dist/access/engine.js +58 -0
  13. package/dist/access/engine.js.map +1 -1
  14. package/dist/access/engine.test.d.ts +2 -0
  15. package/dist/access/engine.test.d.ts.map +1 -0
  16. package/dist/access/engine.test.js +56 -0
  17. package/dist/access/engine.test.js.map +1 -0
  18. package/dist/access/errors.d.ts +28 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +38 -2
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-access.d.ts +12 -0
  23. package/dist/access/field-access.d.ts.map +1 -1
  24. package/dist/access/field-access.js +22 -2
  25. package/dist/access/field-access.js.map +1 -1
  26. package/dist/access/field-access.test.js +66 -0
  27. package/dist/access/field-access.test.js.map +1 -1
  28. package/dist/access/field-visibility.d.ts +2 -1
  29. package/dist/access/field-visibility.d.ts.map +1 -1
  30. package/dist/access/field-visibility.js +45 -3
  31. package/dist/access/field-visibility.js.map +1 -1
  32. package/dist/access/index.d.ts +5 -2
  33. package/dist/access/index.d.ts.map +1 -1
  34. package/dist/access/index.js +4 -2
  35. package/dist/access/index.js.map +1 -1
  36. package/dist/config/types.d.ts +76 -10
  37. package/dist/config/types.d.ts.map +1 -1
  38. package/dist/context/index.d.ts +19 -1
  39. package/dist/context/index.d.ts.map +1 -1
  40. package/dist/context/index.js +61 -17
  41. package/dist/context/index.js.map +1 -1
  42. package/dist/context/nested-operations.d.ts.map +1 -1
  43. package/dist/context/nested-operations.js +54 -13
  44. package/dist/context/nested-operations.js.map +1 -1
  45. package/dist/context/write-pipeline.d.ts +3 -2
  46. package/dist/context/write-pipeline.d.ts.map +1 -1
  47. package/dist/context/write-pipeline.js +8 -5
  48. package/dist/context/write-pipeline.js.map +1 -1
  49. package/dist/fields/index.d.ts +13 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +50 -5
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  54. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  55. package/dist/fields/relationship-foreign-key.test.js +18 -0
  56. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  57. package/dist/index.d.ts +4 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +14 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/lib/case-utils.d.ts +15 -0
  62. package/dist/lib/case-utils.d.ts.map +1 -1
  63. package/dist/lib/case-utils.js +21 -0
  64. package/dist/lib/case-utils.js.map +1 -1
  65. package/dist/lib/case-utils.test.js +23 -1
  66. package/dist/lib/case-utils.test.js.map +1 -1
  67. package/dist/lib/prisma-errors.d.ts +38 -0
  68. package/dist/lib/prisma-errors.d.ts.map +1 -0
  69. package/dist/lib/prisma-errors.js +53 -0
  70. package/dist/lib/prisma-errors.js.map +1 -0
  71. package/dist/mcp/constants.d.ts +23 -0
  72. package/dist/mcp/constants.d.ts.map +1 -0
  73. package/dist/mcp/constants.js +23 -0
  74. package/dist/mcp/constants.js.map +1 -0
  75. package/dist/mcp/field-schema.d.ts +8 -0
  76. package/dist/mcp/field-schema.d.ts.map +1 -0
  77. package/dist/mcp/field-schema.js +69 -0
  78. package/dist/mcp/field-schema.js.map +1 -0
  79. package/dist/mcp/handler.d.ts.map +1 -1
  80. package/dist/mcp/handler.js +76 -90
  81. package/dist/mcp/handler.js.map +1 -1
  82. package/dist/mcp/projection.d.ts +62 -0
  83. package/dist/mcp/projection.d.ts.map +1 -0
  84. package/dist/mcp/projection.js +390 -0
  85. package/dist/mcp/projection.js.map +1 -0
  86. package/dist/utils/password.d.ts +3 -1
  87. package/dist/utils/password.d.ts.map +1 -1
  88. package/dist/utils/password.js +4 -2
  89. package/dist/utils/password.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/access/access-filter.test.ts +278 -12
  92. package/src/access/access-filter.ts +207 -19
  93. package/src/access/engine.test.ts +67 -0
  94. package/src/access/engine.ts +81 -1
  95. package/src/access/errors.ts +42 -2
  96. package/src/access/field-access.test.ts +78 -0
  97. package/src/access/field-access.ts +34 -2
  98. package/src/access/field-visibility.ts +75 -10
  99. package/src/access/index.ts +14 -0
  100. package/src/config/types.ts +76 -10
  101. package/src/context/index.ts +95 -15
  102. package/src/context/nested-operations.ts +61 -16
  103. package/src/context/write-pipeline.ts +8 -4
  104. package/src/fields/index.ts +55 -5
  105. package/src/fields/relationship-foreign-key.test.ts +29 -0
  106. package/src/index.ts +17 -1
  107. package/src/lib/case-utils.test.ts +32 -0
  108. package/src/lib/case-utils.ts +26 -0
  109. package/src/lib/prisma-errors.ts +86 -0
  110. package/src/mcp/constants.ts +24 -0
  111. package/src/mcp/field-schema.ts +84 -0
  112. package/src/mcp/handler.ts +109 -107
  113. package/src/mcp/projection.ts +497 -0
  114. package/src/utils/password.ts +5 -3
  115. package/tests/access-relationships.test.ts +3 -3
  116. package/tests/access.test.ts +83 -0
  117. package/tests/context.test.ts +259 -12
  118. package/tests/field-types.test.ts +113 -0
  119. package/tests/mcp-fields-projection-access.test.ts +250 -0
  120. package/tests/mcp-handler.test.ts +695 -4
  121. package/tests/nested-access-and-hooks.test.ts +65 -0
  122. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  123. package/tests/password-types.test.ts +28 -0
  124. package/tests/password.test.ts +16 -2
  125. package/tests/prisma-errors.test.ts +141 -0
  126. package/tests/singleton.test.ts +9 -1
  127. package/tests/to-one-include-access-filter.test.ts +172 -0
  128. package/tests/with-session.test.ts +243 -0
  129. package/tests/write-pipeline.test.ts +29 -0
  130. package/tsconfig.tsbuildinfo +1 -1
@@ -895,6 +895,224 @@ describe('getContext', () => {
895
895
  const created = result as { created: boolean; fieldErrors?: Record<string, string> }
896
896
  expect(created.fieldErrors?.title).toBeDefined()
897
897
  })
898
+
899
+ // Prisma 7 driver adapters (verified against @prisma/adapter-pg and PGlite,
900
+ // issue #979) leave `meta.target` undefined and put the equivalent data at
901
+ // `meta.driverAdapterError.cause` instead, as free-text-adjacent structured
902
+ // data rather than the documented shape.
903
+ describe('P2002 under Prisma 7 driver adapters (issue #979)', () => {
904
+ it('produces per-field errors for a composite unique violation', async () => {
905
+ mockPrisma.post.create.mockRejectedValue({
906
+ code: 'P2002',
907
+ meta: {
908
+ modelName: 'Post',
909
+ driverAdapterError: {
910
+ name: 'DriverAdapterError',
911
+ cause: {
912
+ originalCode: '23505',
913
+ originalMessage:
914
+ 'duplicate key value violates unique constraint "post_title_content_key"',
915
+ kind: 'UniqueConstraintViolation',
916
+ constraint: { fields: ['title', 'content'] },
917
+ },
918
+ },
919
+ },
920
+ })
921
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
922
+
923
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
924
+ const result = await context.serverAction({
925
+ listKey: 'Post',
926
+ action: 'createRelated',
927
+ data: { title: 'Dup', content: 'Dup' },
928
+ field: 'author',
929
+ parentId: 'u1',
930
+ })
931
+
932
+ expect(result).toMatchObject({ created: false })
933
+ const created = result as { created: boolean; fieldErrors?: Record<string, string> }
934
+ expect(created.fieldErrors?.title).toBeDefined()
935
+ expect(created.fieldErrors?.content).toBeDefined()
936
+ })
937
+
938
+ it('produces a field error for a single-column unique violation', async () => {
939
+ mockPrisma.post.create.mockRejectedValue({
940
+ code: 'P2002',
941
+ meta: {
942
+ modelName: 'Post',
943
+ driverAdapterError: {
944
+ cause: {
945
+ originalMessage:
946
+ 'duplicate key value violates unique constraint "post_title_key"',
947
+ constraint: { fields: ['title'] },
948
+ },
949
+ },
950
+ },
951
+ })
952
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
953
+
954
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
955
+ const result = await context.serverAction({
956
+ listKey: 'Post',
957
+ action: 'createRelated',
958
+ data: { title: 'Dup' },
959
+ field: 'author',
960
+ parentId: 'u1',
961
+ })
962
+
963
+ const created = result as { created: boolean; fieldErrors?: Record<string, string> }
964
+ expect(created.fieldErrors?.title).toBeDefined()
965
+ })
966
+
967
+ it('strips quotes so a camelCase column is keyed correctly (not left quoted)', async () => {
968
+ // Postgres quotes an identifier in `constraint.fields` only when it
969
+ // needed quoting — a camelCase column arrives as `"authorId"`. A naive
970
+ // fix that skips stripping would key fieldErrors by the literal string
971
+ // `"authorId"` (quotes included), missing the real field name.
972
+ mockPrisma.post.create.mockRejectedValue({
973
+ code: 'P2002',
974
+ meta: {
975
+ driverAdapterError: {
976
+ cause: {
977
+ originalMessage:
978
+ 'duplicate key value violates unique constraint "post_authorId_key"',
979
+ constraint: { fields: ['"authorId"'] },
980
+ },
981
+ },
982
+ },
983
+ })
984
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
985
+
986
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
987
+ const result = await context.serverAction({
988
+ listKey: 'Post',
989
+ action: 'createRelated',
990
+ data: { title: 'Dup' },
991
+ field: 'author',
992
+ parentId: 'u1',
993
+ })
994
+
995
+ const created = result as { created: boolean; fieldErrors?: Record<string, string> }
996
+ expect(created.fieldErrors).toEqual({ authorId: 'This value is already in use' })
997
+ })
998
+
999
+ it('humanizes a camelCase field name in the message instead of running words together', async () => {
1000
+ // Before this fix, `target` was always empty under a driver adapter, so
1001
+ // this label-formatting path never ran for a camelCase column. Recovering
1002
+ // real column names makes it reachable — verify it reads as words, not
1003
+ // "tenantid".
1004
+ const camelCaseConfig: OpenSaasConfig = {
1005
+ ...config,
1006
+ lists: {
1007
+ ...config.lists,
1008
+ Post: {
1009
+ ...config.lists.Post,
1010
+ fields: { ...config.lists.Post.fields, tenantSlug: { type: 'text' } },
1011
+ },
1012
+ },
1013
+ }
1014
+ mockPrisma.post.create.mockRejectedValue({
1015
+ code: 'P2002',
1016
+ meta: {
1017
+ driverAdapterError: {
1018
+ cause: {
1019
+ originalMessage:
1020
+ 'duplicate key value violates unique constraint "post_tenantSlug_key"',
1021
+ constraint: { fields: ['"tenantSlug"'] },
1022
+ },
1023
+ },
1024
+ },
1025
+ })
1026
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
1027
+
1028
+ const context = await getContext(camelCaseConfig, mockPrisma, { userId: 'u1' })
1029
+ const result = await context.serverAction({
1030
+ listKey: 'Post',
1031
+ action: 'createRelated',
1032
+ data: { title: 'Dup' },
1033
+ field: 'author',
1034
+ parentId: 'u1',
1035
+ })
1036
+
1037
+ const created = result as { created: boolean; fieldErrors?: Record<string, string> }
1038
+ expect(created.fieldErrors).toEqual({
1039
+ tenantSlug: 'This tenant slug is already in use',
1040
+ })
1041
+ })
1042
+
1043
+ it('leaves an already-populated meta.target unaffected (existing path still wins)', async () => {
1044
+ mockPrisma.post.create.mockRejectedValue({
1045
+ code: 'P2002',
1046
+ meta: {
1047
+ target: ['title'],
1048
+ // Present but must be ignored — meta.target already answers the question.
1049
+ driverAdapterError: {
1050
+ cause: {
1051
+ originalMessage: 'duplicate key value violates unique constraint "other_key"',
1052
+ constraint: { fields: ['content'] },
1053
+ },
1054
+ },
1055
+ },
1056
+ })
1057
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
1058
+
1059
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
1060
+ const result = await context.serverAction({
1061
+ listKey: 'Post',
1062
+ action: 'createRelated',
1063
+ data: { title: 'Dup' },
1064
+ field: 'author',
1065
+ parentId: 'u1',
1066
+ })
1067
+
1068
+ const created = result as { created: boolean; fieldErrors?: Record<string, string> }
1069
+ expect(created.fieldErrors).toEqual({ title: 'This title is already in use' })
1070
+ })
1071
+
1072
+ it('falls back to the generic message (not a throw) when nothing is recoverable', async () => {
1073
+ mockPrisma.post.create.mockRejectedValue({ code: 'P2002', meta: {} })
1074
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
1075
+
1076
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
1077
+ const result = await context.serverAction({
1078
+ listKey: 'Post',
1079
+ action: 'createRelated',
1080
+ data: { title: 'Dup' },
1081
+ field: 'author',
1082
+ parentId: 'u1',
1083
+ })
1084
+
1085
+ expect(result).toEqual({
1086
+ created: false,
1087
+ error: 'A record with this value already exists',
1088
+ fieldErrors: {},
1089
+ })
1090
+ })
1091
+
1092
+ it('leaves a non-P2002 Prisma error unaffected', async () => {
1093
+ mockPrisma.post.create.mockRejectedValue({
1094
+ code: 'P2025',
1095
+ meta: { driverAdapterError: { cause: { constraint: { fields: ['title'] } } } },
1096
+ message: 'Record to update not found',
1097
+ })
1098
+ mockPrisma.user.findUnique.mockResolvedValue({ id: 'u1' })
1099
+
1100
+ const context = await getContext(config, mockPrisma, { userId: 'u1' })
1101
+ const result = await context.serverAction({
1102
+ listKey: 'Post',
1103
+ action: 'createRelated',
1104
+ data: { title: 'Dup' },
1105
+ field: 'author',
1106
+ parentId: 'u1',
1107
+ })
1108
+
1109
+ expect(result).toEqual({
1110
+ created: false,
1111
+ error: 'Record to update not found',
1112
+ fieldErrors: {},
1113
+ })
1114
+ })
1115
+ })
898
1116
  })
899
1117
 
900
1118
  describe('relationshipOptions', () => {
@@ -2194,7 +2412,7 @@ describe('getContext', () => {
2194
2412
  }
2195
2413
  }
2196
2414
 
2197
- it('findUnique inside a resolveOutput hook row-scopes the relation instead of dropping the where', async () => {
2415
+ it('findUnique inside a resolveOutput hook still fetches the relation, unscoped at the Prisma level (#974)', async () => {
2198
2416
  let innerIncludeSeen: unknown
2199
2417
  const hookConfig = configWithResolveOutputProbe({ post: true }, (include) => {
2200
2418
  innerIncludeSeen = include
@@ -2206,9 +2424,11 @@ describe('getContext', () => {
2206
2424
  const context = await getContext(hookConfig, relPrisma, null)
2207
2425
  await context.db.author.findUnique({ where: { id: 'a1' } })
2208
2426
 
2209
- // `post` is still fetched (not dropped) but now carries Post's own
2210
- // query-access `where` it is no longer a bare, unscoped `true`.
2211
- expect(innerIncludeSeen).toEqual({ post: { where: { status: { equals: 'published' } } } })
2427
+ // `post` is still fetched (not dropped). It carries no `where` — Post
2428
+ // is a to-one relation here, and Prisma rejects a `where` on a to-one
2429
+ // include (#974); Post's own access filter is enforced afterward via
2430
+ // a batched existence check instead (see access-filter.test.ts).
2431
+ expect(innerIncludeSeen).toEqual({ post: true })
2212
2432
  })
2213
2433
 
2214
2434
  it('findMany inside a resolveOutput hook scopes the relation but does not auto-expand its nested include', async () => {
@@ -2227,10 +2447,10 @@ describe('getContext', () => {
2227
2447
  await context.db.author.findMany()
2228
2448
 
2229
2449
  // The caller's own nested selection (`post.include.author`) is honoured
2230
- // as-is (access control does not auto-descend further here), while
2231
- // `post` itself picks up its access `where`.
2450
+ // as-is (access control does not auto-descend further here). `post`
2451
+ // itself carries no `where` see the findUnique test above (#974).
2232
2452
  expect(innerIncludeSeen).toEqual({
2233
- post: { where: { status: { equals: 'published' } }, include: { author: true } },
2453
+ post: { include: { author: true } },
2234
2454
  })
2235
2455
  })
2236
2456
  })
@@ -2289,7 +2509,7 @@ describe('getContext', () => {
2289
2509
  expect(chainPrisma.c0.findMany).not.toHaveBeenCalled()
2290
2510
  })
2291
2511
 
2292
- it('still returns correctly row-scoped data for the same include one hop shallower', async () => {
2512
+ it('still applies row-scoping at the deepest hop for the same include one hop shallower', async () => {
2293
2513
  const chainLength = READ_INCLUDE_MAX_DEPTH + 1
2294
2514
  const chainConfig: OpenSaasConfig = {
2295
2515
  db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
@@ -2300,7 +2520,20 @@ describe('getContext', () => {
2300
2520
  for (let i = 0; i < chainLength; i++) {
2301
2521
  chainPrisma[`c${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
2302
2522
  }
2303
- chainPrisma.c0.findMany.mockResolvedValue([])
2523
+
2524
+ // Every hop here is to-one (no `many: true`), so none of them can
2525
+ // carry a `where` on the Prisma include (#974) — the chain comes
2526
+ // back unscoped at the Prisma level, one nested object per hop, and
2527
+ // row-scoping is enforced afterward via a batched existence check
2528
+ // per hop instead.
2529
+ let deepestItem: Record<string, unknown> = { id: `id${chainLength - 1}`, name: 'leaf' }
2530
+ for (let i = chainLength - 2; i >= 0; i--) {
2531
+ deepestItem = { id: `id${i}`, name: 'x', next: deepestItem }
2532
+ }
2533
+ chainPrisma.c0.findMany.mockResolvedValue([deepestItem])
2534
+ for (let i = 1; i < chainLength; i++) {
2535
+ chainPrisma[`c${i}`].findMany.mockResolvedValue([{ id: `id${i}` }])
2536
+ }
2304
2537
 
2305
2538
  const context = await getContext(chainConfig, chainPrisma, null)
2306
2539
 
@@ -2308,13 +2541,27 @@ describe('getContext', () => {
2308
2541
  include: { next: nestedCallerInclude(READ_INCLUDE_MAX_DEPTH - 1) },
2309
2542
  })
2310
2543
 
2311
- // Walk the built include down to the last list — it must carry that
2312
- // list's own access `where`, proving row scoping, not just "no throw".
2544
+ // Walk the built include down to the last list — no hop carries a
2545
+ // `where` (they are all to-one).
2313
2546
  let entry = chainPrisma.c0.findMany.mock.calls[0][0].include.next
2314
2547
  for (let i = 1; i < READ_INCLUDE_MAX_DEPTH; i++) {
2548
+ expect(entry.where).toBeUndefined()
2315
2549
  entry = entry.include.next
2316
2550
  }
2317
- expect(entry.where).toEqual({ ownerId: { equals: `C${READ_INCLUDE_MAX_DEPTH}` } })
2551
+ expect(entry.where).toBeUndefined()
2552
+
2553
+ // The deepest hop's own existence check still carries that list's
2554
+ // access filter — row scoping reaches all the way down, just not
2555
+ // through the Prisma `include` itself.
2556
+ expect(chainPrisma[`c${READ_INCLUDE_MAX_DEPTH}`].findMany).toHaveBeenCalledWith({
2557
+ where: {
2558
+ AND: [
2559
+ { ownerId: { equals: `C${READ_INCLUDE_MAX_DEPTH}` } },
2560
+ { id: { in: [`id${READ_INCLUDE_MAX_DEPTH}`] } },
2561
+ ],
2562
+ },
2563
+ select: { id: true },
2564
+ })
2318
2565
  })
2319
2566
  })
2320
2567
 
@@ -307,6 +307,29 @@ describe('Field Types', () => {
307
307
  expect(prismaType.type).toBe('Int')
308
308
  expect(prismaType.modifiers).toBe('@db.BigInt')
309
309
  })
310
+
311
+ test('isIndexed: true requests a block-level index', () => {
312
+ const field = integer({ isIndexed: true })
313
+ const prismaType = field.getPrismaType('rank')
314
+
315
+ expect(prismaType.index).toBe(true)
316
+ })
317
+
318
+ test("isIndexed: 'unique' generates @unique modifier", () => {
319
+ const field = integer({ isIndexed: 'unique' })
320
+ const prismaType = field.getPrismaType('rank')
321
+
322
+ expect(prismaType.modifiers).toContain('@unique')
323
+ expect(prismaType.index).toBeUndefined()
324
+ })
325
+
326
+ test('no isIndexed generates neither modifier nor index', () => {
327
+ const field = integer()
328
+ const prismaType = field.getPrismaType('rank')
329
+
330
+ expect(prismaType.modifiers).not.toContain('@unique')
331
+ expect(prismaType.index).toBeUndefined()
332
+ })
310
333
  })
311
334
 
312
335
  describe('getTypeScriptType', () => {
@@ -679,6 +702,29 @@ describe('Field Types', () => {
679
702
  expect(prismaType.type).toBe('DateTime')
680
703
  expect(prismaType.modifiers).toContain('@db.Timestamptz')
681
704
  })
705
+
706
+ test('isIndexed: true requests a block-level index', () => {
707
+ const field = timestamp({ isIndexed: true })
708
+ const prismaType = field.getPrismaType('publishedAt')
709
+
710
+ expect(prismaType.index).toBe(true)
711
+ })
712
+
713
+ test("isIndexed: 'unique' generates @unique modifier", () => {
714
+ const field = timestamp({ isIndexed: 'unique' })
715
+ const prismaType = field.getPrismaType('publishedAt')
716
+
717
+ expect(prismaType.modifiers).toContain('@unique')
718
+ expect(prismaType.index).toBeUndefined()
719
+ })
720
+
721
+ test('no isIndexed generates neither modifier nor index', () => {
722
+ const field = timestamp()
723
+ const prismaType = field.getPrismaType('publishedAt')
724
+
725
+ expect(prismaType.modifiers).not.toContain('@unique')
726
+ expect(prismaType.index).toBeUndefined()
727
+ })
682
728
  })
683
729
 
684
730
  describe('getTypeScriptType', () => {
@@ -810,6 +856,27 @@ describe('Field Types', () => {
810
856
  )
811
857
  })
812
858
  })
859
+
860
+ describe('ui.listView.defaultColumn (issue #1018)', () => {
861
+ test('is excluded from default admin table columns by default', () => {
862
+ const field = password()
863
+
864
+ expect(field.ui?.listView?.defaultColumn).toBe(false)
865
+ })
866
+
867
+ test('can be opted back into default columns explicitly', () => {
868
+ const field = password({ ui: { listView: { defaultColumn: true } } })
869
+
870
+ expect(field.ui?.listView?.defaultColumn).toBe(true)
871
+ })
872
+
873
+ test('preserves other caller-supplied ui options', () => {
874
+ const field = password({ ui: { description: 'Account password' } })
875
+
876
+ expect(field.ui?.description).toBe('Account password')
877
+ expect(field.ui?.listView?.defaultColumn).toBe(false)
878
+ })
879
+ })
813
880
  })
814
881
 
815
882
  describe('select field', () => {
@@ -905,6 +972,52 @@ describe('Field Types', () => {
905
972
  expect(prismaType.type).toBe('String')
906
973
  expect(prismaType.modifiers).toBe(' @default("draft")')
907
974
  })
975
+
976
+ test('isIndexed: true requests a block-level index on the default string column', () => {
977
+ const field = select({
978
+ options: [{ label: 'Draft', value: 'draft' }],
979
+ isIndexed: true,
980
+ })
981
+ const prismaType = field.getPrismaType('status')
982
+
983
+ expect(prismaType.type).toBe('String')
984
+ expect(prismaType.index).toBe(true)
985
+ })
986
+
987
+ test("isIndexed: 'unique' generates @unique on the default string column", () => {
988
+ const field = select({
989
+ options: [{ label: 'Draft', value: 'draft' }],
990
+ isIndexed: 'unique',
991
+ })
992
+ const prismaType = field.getPrismaType('status')
993
+
994
+ expect(prismaType.modifiers).toContain('@unique')
995
+ expect(prismaType.index).toBeUndefined()
996
+ })
997
+
998
+ test('isIndexed: true requests a block-level index on a native-enum column', () => {
999
+ const field = select({
1000
+ options: [{ label: 'Draft', value: 'draft' }],
1001
+ db: { type: 'enum' },
1002
+ isIndexed: true,
1003
+ })
1004
+ const prismaType = field.getPrismaType('status', undefined, 'Post')
1005
+
1006
+ expect(prismaType.type).toBe('PostStatus')
1007
+ expect(prismaType.index).toBe(true)
1008
+ })
1009
+
1010
+ test("isIndexed: 'unique' generates @unique on a native-enum column", () => {
1011
+ const field = select({
1012
+ options: [{ label: 'Draft', value: 'draft' }],
1013
+ db: { type: 'enum' },
1014
+ isIndexed: 'unique',
1015
+ })
1016
+ const prismaType = field.getPrismaType('status', undefined, 'Post')
1017
+
1018
+ expect(prismaType.modifiers).toContain('@unique')
1019
+ expect(prismaType.index).toBeUndefined()
1020
+ })
908
1021
  })
909
1022
 
910
1023
  describe('getTypeScriptType', () => {