@supatype/cli 0.1.0-alpha.7 → 0.1.0-alpha.8

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 (123) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +67 -62
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/app/proxy-dev-app.d.ts +13 -0
  5. package/dist/app/proxy-dev-app.d.ts.map +1 -0
  6. package/dist/app/proxy-dev-app.js +53 -0
  7. package/dist/app/proxy-dev-app.js.map +1 -0
  8. package/dist/binary-cache.d.ts +5 -0
  9. package/dist/binary-cache.d.ts.map +1 -1
  10. package/dist/binary-cache.js +13 -0
  11. package/dist/binary-cache.js.map +1 -1
  12. package/dist/commands/cloud.d.ts +11 -3
  13. package/dist/commands/cloud.d.ts.map +1 -1
  14. package/dist/commands/cloud.js +33 -25
  15. package/dist/commands/cloud.js.map +1 -1
  16. package/dist/commands/deploy.d.ts.map +1 -1
  17. package/dist/commands/deploy.js +3 -17
  18. package/dist/commands/deploy.js.map +1 -1
  19. package/dist/commands/dev.d.ts +3 -3
  20. package/dist/commands/dev.d.ts.map +1 -1
  21. package/dist/commands/dev.js +66 -59
  22. package/dist/commands/dev.js.map +1 -1
  23. package/dist/commands/diff.d.ts.map +1 -1
  24. package/dist/commands/diff.js +11 -1
  25. package/dist/commands/diff.js.map +1 -1
  26. package/dist/commands/init.js +16 -3
  27. package/dist/commands/init.js.map +1 -1
  28. package/dist/commands/push.d.ts.map +1 -1
  29. package/dist/commands/push.js +42 -12
  30. package/dist/commands/push.js.map +1 -1
  31. package/dist/commands/update.d.ts.map +1 -1
  32. package/dist/commands/update.js +16 -0
  33. package/dist/commands/update.js.map +1 -1
  34. package/dist/dev-compose.d.ts +17 -0
  35. package/dist/dev-compose.d.ts.map +1 -0
  36. package/dist/dev-compose.js +374 -0
  37. package/dist/dev-compose.js.map +1 -0
  38. package/dist/diff-output.d.ts +4 -0
  39. package/dist/diff-output.d.ts.map +1 -0
  40. package/dist/diff-output.js +12 -0
  41. package/dist/diff-output.js.map +1 -0
  42. package/dist/docker-postgres.d.ts +21 -3
  43. package/dist/docker-postgres.d.ts.map +1 -1
  44. package/dist/docker-postgres.js +130 -18
  45. package/dist/docker-postgres.js.map +1 -1
  46. package/dist/engine-client.d.ts +5 -3
  47. package/dist/engine-client.d.ts.map +1 -1
  48. package/dist/engine-client.js +2 -1
  49. package/dist/engine-client.js.map +1 -1
  50. package/dist/kong-config.d.ts +4 -0
  51. package/dist/kong-config.d.ts.map +1 -1
  52. package/dist/kong-config.js +12 -1
  53. package/dist/kong-config.js.map +1 -1
  54. package/dist/process-manager.d.ts +2 -0
  55. package/dist/process-manager.d.ts.map +1 -1
  56. package/dist/process-manager.js +16 -1
  57. package/dist/process-manager.js.map +1 -1
  58. package/dist/project-config.d.ts +21 -1
  59. package/dist/project-config.d.ts.map +1 -1
  60. package/dist/project-config.js +15 -0
  61. package/dist/project-config.js.map +1 -1
  62. package/dist/runtime-routes.d.ts +9 -0
  63. package/dist/runtime-routes.d.ts.map +1 -1
  64. package/dist/runtime-routes.js +75 -12
  65. package/dist/runtime-routes.js.map +1 -1
  66. package/dist/schema-ast-v2.d.ts +127 -0
  67. package/dist/schema-ast-v2.d.ts.map +1 -0
  68. package/dist/schema-ast-v2.js +226 -0
  69. package/dist/schema-ast-v2.js.map +1 -0
  70. package/dist/self-host-compose.d.ts +12 -4
  71. package/dist/self-host-compose.d.ts.map +1 -1
  72. package/dist/self-host-compose.js +146 -35
  73. package/dist/self-host-compose.js.map +1 -1
  74. package/dist/studio-admin-roles.d.ts +7 -0
  75. package/dist/studio-admin-roles.d.ts.map +1 -0
  76. package/dist/studio-admin-roles.js +14 -0
  77. package/dist/studio-admin-roles.js.map +1 -0
  78. package/dist/studio-dev-server.d.ts +22 -0
  79. package/dist/studio-dev-server.d.ts.map +1 -0
  80. package/dist/studio-dev-server.js +28 -0
  81. package/dist/studio-dev-server.js.map +1 -0
  82. package/dist/type-extractor.d.ts +3 -30
  83. package/dist/type-extractor.d.ts.map +1 -1
  84. package/dist/type-extractor.js +485 -148
  85. package/dist/type-extractor.js.map +1 -1
  86. package/dist/type-resolver.d.ts +33 -0
  87. package/dist/type-resolver.d.ts.map +1 -0
  88. package/dist/type-resolver.js +338 -0
  89. package/dist/type-resolver.js.map +1 -0
  90. package/package.json +1 -1
  91. package/src/TYPE-RESOLUTION.md +294 -0
  92. package/src/app/proxy-dev-app.ts +67 -0
  93. package/src/binary-cache.ts +20 -0
  94. package/src/commands/cloud.ts +40 -30
  95. package/src/commands/deploy.ts +3 -18
  96. package/src/commands/dev.ts +72 -69
  97. package/src/commands/diff.ts +11 -1
  98. package/src/commands/init.ts +16 -3
  99. package/src/commands/push.ts +49 -13
  100. package/src/commands/update.ts +17 -0
  101. package/src/dev-compose.ts +455 -0
  102. package/src/diff-output.ts +12 -0
  103. package/src/docker-postgres.ts +184 -27
  104. package/src/engine-client.ts +9 -4
  105. package/src/kong-config.ts +16 -1
  106. package/src/process-manager.ts +18 -1
  107. package/src/project-config.ts +34 -1
  108. package/src/runtime-routes.ts +87 -12
  109. package/src/schema-ast-v2.ts +324 -0
  110. package/src/self-host-compose.ts +168 -36
  111. package/src/studio-admin-roles.ts +16 -0
  112. package/src/studio-dev-server.ts +53 -0
  113. package/src/type-extractor.ts +649 -186
  114. package/src/type-resolver.ts +457 -0
  115. package/tests/config.test.ts +34 -3
  116. package/tests/docker-postgres.test.ts +39 -0
  117. package/tests/normalize-admin-config.test.ts +48 -0
  118. package/tests/proxy-dev-app.test.ts +33 -0
  119. package/tests/runtime-contract.test.ts +119 -4
  120. package/tests/studio-admin-roles.test.ts +27 -0
  121. package/tests/type-extractor.test.ts +607 -23
  122. package/tests/type-resolver.test.ts +59 -0
  123. package/tsconfig.tsbuildinfo +1 -1
@@ -2,10 +2,19 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
2
2
  import { tmpdir } from "node:os"
3
3
  import { join } from "node:path"
4
4
  import { afterEach, describe, expect, it } from "vitest"
5
+ import type { ModelAstV2 } from "../src/schema-ast-v2.js"
5
6
  import { extractSchemaAstFromTypes } from "../src/type-extractor.js"
6
7
 
7
8
  const dirs: string[] = []
8
9
 
10
+ function tableName(model: ModelAstV2 | undefined): string | undefined {
11
+ return model?.annotations.db.tableName
12
+ }
13
+
14
+ function modelAccess(model: ModelAstV2 | undefined): Record<string, unknown> {
15
+ return model?.annotations.platform.access ?? {}
16
+ }
17
+
9
18
  afterEach(() => {
10
19
  for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
11
20
  })
@@ -42,27 +51,32 @@ export type Comment = Model<{
42
51
 
43
52
  const ast = extractSchemaAstFromTypes(schemaPath, dir)
44
53
  expect(ast).not.toBeNull()
54
+ expect(ast?.astVersion).toBe(2)
45
55
  expect(ast?.models).toHaveLength(2)
46
56
  const post = ast?.models.find((m) => m.name === "Post")
47
57
  const comment = ast?.models.find((m) => m.name === "Comment")
48
- expect(post?.tableName).toBe("post")
58
+ expect(tableName(post)).toBe("post")
49
59
  expect(comment?.fields["post"]).toMatchObject({
50
60
  kind: "relation",
51
61
  cardinality: "belongsTo",
52
62
  target: "Post",
53
- foreignKey: "post_id",
63
+ annotations: { db: { foreignKey: "post_id" } },
54
64
  })
55
- expect(post?.fields["id"]).toMatchObject({ kind: "uuid", pgType: "UUID" })
56
65
  expect(post?.fields["id"]).toMatchObject({
66
+ kind: "uuid",
67
+ annotations: { db: { pgType: "UUID", unique: true } },
57
68
  primaryKey: true,
58
- unique: true,
59
69
  required: true,
60
70
  default: { kind: "genRandomUuid" },
61
71
  })
62
- expect(post?.fields["slug"]).toMatchObject({ kind: "slug", unique: true, from: "title" })
63
- expect(post?.access["read"]).toEqual({ type: "public" })
64
- expect(post?.access["update"]).toEqual({ type: "owner", field: "author_id" })
65
- expect(post?.access["delete"]).toEqual({ type: "owner", field: "author_id" })
72
+ expect(post?.fields["slug"]).toMatchObject({
73
+ kind: "slug",
74
+ from: "title",
75
+ annotations: { db: { unique: true } },
76
+ })
77
+ expect(modelAccess(post)["read"]).toEqual({ type: "public" })
78
+ expect(modelAccess(post)["update"]).toEqual({ type: "owner", field: "author_id" })
79
+ expect(modelAccess(post)["delete"]).toEqual({ type: "owner", field: "author_id" })
66
80
  })
67
81
 
68
82
  it("emits DEFAULT now for created_at / updated_at timestamp columns", () => {
@@ -83,12 +97,12 @@ export type Entry = Model<{ id: UUID; created_at: Timestamp; updated_at: Timesta
83
97
  const entry = ast?.models.find((m) => m.name === "Entry")
84
98
  expect(entry?.fields["created_at"]).toMatchObject({
85
99
  kind: "datetime",
86
- serverGenerated: true,
100
+ annotations: { db: { serverGenerated: true, pgType: "TIMESTAMP WITH TIME ZONE" } },
87
101
  default: { kind: "now" },
88
102
  })
89
103
  expect(entry?.fields["updated_at"]).toMatchObject({
90
104
  kind: "datetime",
91
- serverGenerated: true,
105
+ annotations: { db: { serverGenerated: true, pgType: "TIMESTAMP WITH TIME ZONE" } },
92
106
  default: { kind: "now" },
93
107
  })
94
108
  })
@@ -113,8 +127,8 @@ export type User = Model<{
113
127
 
114
128
  const ast = extractSchemaAstFromTypes(schemaPath, dir)
115
129
  const user = ast?.models.find((m) => m.name === "User")
116
- expect(user?.access["update"]).toEqual({ type: "owner", field: "id" })
117
- expect(user?.access["delete"]).toEqual({ type: "owner", field: "id" })
130
+ expect(modelAccess(user)["update"]).toEqual({ type: "owner", field: "id" })
131
+ expect(modelAccess(user)["delete"]).toEqual({ type: "owner", field: "id" })
118
132
  })
119
133
 
120
134
  it("maps SupatypeAuthUser relations and OwnerFrom relation keys", () => {
@@ -142,10 +156,10 @@ export type Post = Model<{
142
156
  kind: "relation",
143
157
  cardinality: "belongsTo",
144
158
  target: "supatype:user",
145
- foreignKey: "auth_user_id",
159
+ annotations: { db: { foreignKey: "auth_user_id" } },
146
160
  })
147
- expect(post?.access["update"]).toEqual({ type: "owner", field: "auth_user_id" })
148
- expect(post?.access["delete"]).toEqual({ type: "owner", field: "auth_user_id" })
161
+ expect(modelAccess(post)["update"]).toEqual({ type: "owner", field: "authUser" })
162
+ expect(modelAccess(post)["delete"]).toEqual({ type: "owner", field: "authUser" })
149
163
  })
150
164
 
151
165
  it("unwraps Default<> so boolean fields stay boolean in the AST", () => {
@@ -165,7 +179,85 @@ export type Flags = Model<{
165
179
  "utf8",
166
180
  )
167
181
  const ast = extractSchemaAstFromTypes(schemaPath, dir)
168
- expect(ast?.models[0]?.fields["isActive"]).toMatchObject({ kind: "boolean", pgType: "BOOLEAN" })
182
+ expect(ast?.models[0]?.fields["isActive"]).toMatchObject({
183
+ kind: "boolean",
184
+ annotations: { db: { pgType: "BOOLEAN" } },
185
+ default: { kind: "value", value: true },
186
+ })
187
+ })
188
+
189
+ it("extracts Default<> literal values for scalars and RichText plain-string defaults", () => {
190
+ const dir = mkdtempSync(join(tmpdir(), "supatype-defaults-"))
191
+ dirs.push(dir)
192
+ const schemaPath = join(dir, "schema.ts")
193
+ writeFileSync(
194
+ schemaPath,
195
+ `
196
+ import type { Model, UUID, Default, Int, RichText } from "@supatype/types"
197
+
198
+ export type Product = Model<{
199
+ id: UUID
200
+ stock: Default<Int, 0>
201
+ blurb: Default<RichText, "Welcome to our shop.">
202
+ }>
203
+ `,
204
+ "utf8",
205
+ )
206
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
207
+ expect(ast?.models[0]?.fields["stock"]).toMatchObject({
208
+ kind: "integer",
209
+ default: { kind: "value", value: 0 },
210
+ })
211
+ expect(ast?.models[0]?.fields["blurb"]).toMatchObject({
212
+ kind: "richText",
213
+ annotations: { db: { pgType: "JSONB" }, platform: { editor: "rich" } },
214
+ default: { kind: "value", value: "Welcome to our shop." },
215
+ })
216
+ })
217
+
218
+ it("extracts RichText<\"…\"> inline default (equivalent to Default<RichText, \"…\">)", () => {
219
+ const dir = mkdtempSync(join(tmpdir(), "supatype-richtext-inline-"))
220
+ dirs.push(dir)
221
+ const schemaPath = join(dir, "schema.ts")
222
+ writeFileSync(
223
+ schemaPath,
224
+ `
225
+ import type { Model, UUID, RichText } from "@supatype/types"
226
+
227
+ export type Page = Model<{
228
+ id: UUID
229
+ intro: RichText<"Welcome to Elmside.">
230
+ }>
231
+ `,
232
+ "utf8",
233
+ )
234
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
235
+ expect(ast?.models[0]?.fields["intro"]).toMatchObject({
236
+ kind: "richText",
237
+ annotations: { db: { pgType: "JSONB" }, platform: { editor: "rich" } },
238
+ default: { kind: "value", value: "Welcome to Elmside." },
239
+ })
240
+ })
241
+
242
+ it("errors when RichText inline default and Default<> are both set", () => {
243
+ const dir = mkdtempSync(join(tmpdir(), "supatype-richtext-double-default-"))
244
+ dirs.push(dir)
245
+ const schemaPath = join(dir, "schema.ts")
246
+ writeFileSync(
247
+ schemaPath,
248
+ `
249
+ import type { Model, UUID, Default, RichText } from "@supatype/types"
250
+
251
+ export type Page = Model<{
252
+ id: UUID
253
+ intro: Default<RichText<"a">, "b">
254
+ }>
255
+ `,
256
+ "utf8",
257
+ )
258
+ expect(() => extractSchemaAstFromTypes(schemaPath, dir)).toThrow(
259
+ /either Default<…> or an inline type default/,
260
+ )
169
261
  })
170
262
 
171
263
  it("extracts Bucket<> config into storageBuckets and field accessMode", () => {
@@ -279,9 +371,15 @@ export type Comment = Model<{
279
371
  )
280
372
  const ast = extractSchemaAstFromTypes(schemaPath, dir)
281
373
  const comment = ast?.models.find((m) => m.name === "Comment")
282
- expect(comment?.fields["author"]).toMatchObject({ foreignKey: "author_id" })
283
- expect(comment?.fields["userId"]).toMatchObject({ foreignKey: "user_id" })
284
- expect(comment?.fields["customerID"]).toMatchObject({ foreignKey: "customer_id" })
374
+ expect(comment?.fields["author"]).toMatchObject({
375
+ annotations: { db: { foreignKey: "author_id" } },
376
+ })
377
+ expect(comment?.fields["userId"]).toMatchObject({
378
+ annotations: { db: { foreignKey: "user_id" } },
379
+ })
380
+ expect(comment?.fields["customerID"]).toMatchObject({
381
+ annotations: { db: { foreignKey: "customer_id" } },
382
+ })
285
383
  })
286
384
 
287
385
  it("extracts EditorReadOnly wrapper as readOnly field metadata", () => {
@@ -305,8 +403,14 @@ export type Doc = Model<{
305
403
  )
306
404
  const ast = extractSchemaAstFromTypes(schemaPath, dir)
307
405
  const doc = ast?.models.find((m) => m.name === "Doc")
308
- expect(doc?.fields["title"]).toMatchObject({ kind: "text", readOnly: true })
309
- expect(doc?.fields["owner"]).toMatchObject({ kind: "relation", readOnly: true })
406
+ expect(doc?.fields["title"]).toMatchObject({
407
+ kind: "text",
408
+ annotations: { platform: { readOnly: true } },
409
+ })
410
+ expect(doc?.fields["owner"]).toMatchObject({
411
+ kind: "relation",
412
+ annotations: { platform: { readOnly: true } },
413
+ })
310
414
  })
311
415
 
312
416
  it("extracts Computed wrapper as readOnly + serverGenerated metadata", () => {
@@ -330,8 +434,7 @@ export type Doc = Model<{
330
434
  expect(doc?.fields["summary"]).toMatchObject({
331
435
  kind: "text",
332
436
  required: false,
333
- readOnly: true,
334
- serverGenerated: true,
437
+ annotations: { db: { serverGenerated: true }, platform: { readOnly: true } },
335
438
  })
336
439
  })
337
440
 
@@ -398,4 +501,485 @@ export type Note = Model<{
398
501
  })
399
502
  expect(new Set(summary?.sources ?? [])).toEqual(new Set(["author", "published_at", "description"]))
400
503
  })
504
+
505
+ it("extracts singleton: true with default _global_ table name", () => {
506
+ const dir = mkdtempSync(join(tmpdir(), "supatype-singleton-"))
507
+ dirs.push(dir)
508
+ const schemaPath = join(dir, "schema.ts")
509
+ writeFileSync(
510
+ schemaPath,
511
+ `
512
+ import type { Model, UUID, Public, Role, Timestamp } from "@supatype/types"
513
+
514
+ export type SiteSettings = Model<{
515
+ id: UUID
516
+ site_name: string
517
+ created_at: Timestamp
518
+ updated_at: Timestamp
519
+ }, {
520
+ singleton: true
521
+ access: { read: Public; update: Role<"supatype_admin"> }
522
+ }>
523
+ `,
524
+ "utf8",
525
+ )
526
+
527
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
528
+ const settings = ast?.models.find((m) => m.name === "SiteSettings")
529
+ expect(tableName(settings)).toBe("_global_site_settings")
530
+ expect(settings?.options).toMatchObject({ singleton: true, timestamps: true })
531
+ })
532
+
533
+ it("respects tableName override on singleton models", () => {
534
+ const dir = mkdtempSync(join(tmpdir(), "supatype-singleton-table-"))
535
+ dirs.push(dir)
536
+ const schemaPath = join(dir, "schema.ts")
537
+ writeFileSync(
538
+ schemaPath,
539
+ `
540
+ import type { Model, UUID, Public } from "@supatype/types"
541
+
542
+ export type Config = Model<{
543
+ id: UUID
544
+ }, {
545
+ singleton: true
546
+ tableName: "config"
547
+ access: { read: Public }
548
+ }>
549
+ `,
550
+ "utf8",
551
+ )
552
+
553
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
554
+ const config = ast?.models.find((m) => m.name === "Config")
555
+ expect(tableName(config)).toBe("config")
556
+ expect(config?.options.singleton).toBe(true)
557
+ })
558
+
559
+ it("infers timestamps from WithTimestamps wrapper", () => {
560
+ const dir = mkdtempSync(join(tmpdir(), "supatype-timestamps-"))
561
+ dirs.push(dir)
562
+ const schemaPath = join(dir, "schema.ts")
563
+ writeFileSync(
564
+ schemaPath,
565
+ `
566
+ import type { Model, UUID, WithTimestamps, Public } from "@supatype/types"
567
+
568
+ export type Post = Model<WithTimestamps<{
569
+ id: UUID
570
+ title: string
571
+ }>, {
572
+ access: { read: Public }
573
+ }>
574
+ `,
575
+ "utf8",
576
+ )
577
+
578
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
579
+ const post = ast?.models.find((m) => m.name === "Post")
580
+ expect(post?.options.timestamps).toBe(true)
581
+ expect(post?.options.singleton).toBeUndefined()
582
+ })
583
+
584
+ it("extracts LocaleConfig into schema AST locales", () => {
585
+ const dir = mkdtempSync(join(tmpdir(), "supatype-types-locale-config-"))
586
+ dirs.push(dir)
587
+ const schemaPath = join(dir, "schema.ts")
588
+ writeFileSync(
589
+ schemaPath,
590
+ `
591
+ import type { LocaleConfig, Model, UUID } from "@supatype/types"
592
+
593
+ export type localeConfig = LocaleConfig<["en", "de"], "en">
594
+
595
+ export type Page = Model<{ id: UUID; title: string }>
596
+ `,
597
+ "utf8",
598
+ )
599
+
600
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
601
+ expect(ast?.locales).toEqual(["en", "de"])
602
+ expect(ast?.defaultLocale).toBe("en")
603
+ })
604
+
605
+ it("marks Localized fields as JSONB with localized:true", () => {
606
+ const dir = mkdtempSync(join(tmpdir(), "supatype-types-localized-field-"))
607
+ dirs.push(dir)
608
+ const schemaPath = join(dir, "schema.ts")
609
+ writeFileSync(
610
+ schemaPath,
611
+ `
612
+ import type { Localized, Model, Optional, RichText, UUID } from "@supatype/types"
613
+
614
+ export type Page = Model<{
615
+ id: UUID
616
+ title: Localized<string>
617
+ body: Localized<RichText>
618
+ subtitle: Optional<Localized<string>>
619
+ }>
620
+ `,
621
+ "utf8",
622
+ )
623
+
624
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
625
+ const page = ast?.models.find((m) => m.name === "Page")
626
+ expect(page?.fields["title"]).toMatchObject({
627
+ kind: "text",
628
+ annotations: { db: { pgType: "JSONB" } },
629
+ localized: true,
630
+ required: true,
631
+ })
632
+ expect(page?.fields["body"]).toMatchObject({
633
+ kind: "richText",
634
+ annotations: { db: { pgType: "JSONB" }, platform: { editor: "rich" } },
635
+ localized: true,
636
+ })
637
+ expect(page?.fields["subtitle"]).toMatchObject({
638
+ kind: "text",
639
+ annotations: { db: { pgType: "JSONB" } },
640
+ localized: true,
641
+ required: false,
642
+ })
643
+ })
644
+
645
+ it("extracts LocalizedModel with auto-localized copy fields", () => {
646
+ const dir = mkdtempSync(join(tmpdir(), "supatype-types-localized-model-"))
647
+ dirs.push(dir)
648
+ const schemaPath = join(dir, "schema.ts")
649
+ writeFileSync(
650
+ schemaPath,
651
+ `
652
+ import type {
653
+ LocalizedModel,
654
+ UUID,
655
+ ImageAsset,
656
+ NotLocalized,
657
+ Blocks,
658
+ Block,
659
+ Bucket,
660
+ } from "@supatype/types"
661
+
662
+ export type marketing = Bucket<"marketing", { accessMode: "public" }>
663
+ export type RuleBlock = Block<"rule", { text: string }>
664
+
665
+ export type Homepage = LocalizedModel<{
666
+ id: UUID
667
+ hero_title: string
668
+ map_url: NotLocalized<string>
669
+ og_image: ImageAsset<marketing, { localized: true }>
670
+ hero_slides: Blocks<RuleBlock>
671
+ }>
672
+ `,
673
+ "utf8",
674
+ )
675
+
676
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
677
+ const homepage = ast?.models.find((m) => m.name === "Homepage")
678
+ expect(homepage?.fields["hero_title"]).toMatchObject({
679
+ kind: "text",
680
+ localized: true,
681
+ annotations: { db: { pgType: "JSONB" } },
682
+ })
683
+ expect(homepage?.fields["map_url"]?.localized).toBeUndefined()
684
+ expect(homepage?.fields["og_image"]).toMatchObject({
685
+ kind: "image",
686
+ localized: true,
687
+ })
688
+ const slides = homepage?.fields["hero_slides"] as { blocks?: { fields: Record<string, unknown> }[] }
689
+ expect(slides?.blocks?.[0]?.fields["text"]).toMatchObject({
690
+ kind: "text",
691
+ localized: true,
692
+ pgType: "JSONB",
693
+ })
694
+ })
695
+
696
+ it("marks Localized<Blocks<...>> as localized column", () => {
697
+ const dir = mkdtempSync(join(tmpdir(), "supatype-types-localized-blocks-col-"))
698
+ dirs.push(dir)
699
+ const schemaPath = join(dir, "schema.ts")
700
+ writeFileSync(
701
+ schemaPath,
702
+ `
703
+ import type { Localized, Model, UUID, Blocks, Block } from "@supatype/types"
704
+
705
+ export type Slide = Block<"slide", { image_path: string }>
706
+
707
+ export type Page = Model<{
708
+ id: UUID
709
+ slides: Localized<Blocks<Slide>>
710
+ }>
711
+ `,
712
+ "utf8",
713
+ )
714
+
715
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
716
+ const page = ast?.models.find((m) => m.name === "Page")
717
+ expect(page?.fields["slides"]).toMatchObject({
718
+ kind: "blocks",
719
+ localized: true,
720
+ annotations: { db: { pgType: "JSONB" } },
721
+ })
722
+ })
723
+
724
+ it("resolves type alias Nullable<T> = Optional<T>", () => {
725
+ const dir = mkdtempSync(join(tmpdir(), "supatype-alias-nullable-"))
726
+ dirs.push(dir)
727
+ const schemaPath = join(dir, "schema.ts")
728
+ writeFileSync(
729
+ schemaPath,
730
+ `
731
+ import type { Model, UUID, Email, Optional } from "@supatype/types"
732
+
733
+ type Nullable<T> = Optional<T>
734
+
735
+ export type User = Model<{
736
+ id: UUID
737
+ email: Nullable<Email>
738
+ }>
739
+ `,
740
+ "utf8",
741
+ )
742
+
743
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
744
+ const user = ast?.models.find((m) => m.name === "User")
745
+ expect(user?.fields["email"]).toMatchObject({
746
+ kind: "email",
747
+ required: false,
748
+ })
749
+ })
750
+
751
+ it("resolves multi-hop type aliases", () => {
752
+ const dir = mkdtempSync(join(tmpdir(), "supatype-alias-multihop-"))
753
+ dirs.push(dir)
754
+ const schemaPath = join(dir, "schema.ts")
755
+ writeFileSync(
756
+ schemaPath,
757
+ `
758
+ import type { Model, UUID, Email, Optional } from "@supatype/types"
759
+
760
+ type Nullable<T> = Optional<T>
761
+ type A = Nullable<Email>
762
+ type B = A
763
+
764
+ export type User = Model<{ id: UUID; email: B }>
765
+ `,
766
+ "utf8",
767
+ )
768
+
769
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
770
+ const user = ast?.models.find((m) => m.name === "User")
771
+ expect(user?.fields["email"]).toMatchObject({
772
+ kind: "email",
773
+ required: false,
774
+ })
775
+ })
776
+
777
+ it("resolves enum string-union type aliases", () => {
778
+ const dir = mkdtempSync(join(tmpdir(), "supatype-alias-enum-"))
779
+ dirs.push(dir)
780
+ const schemaPath = join(dir, "schema.ts")
781
+ writeFileSync(
782
+ schemaPath,
783
+ `
784
+ import type { Model, UUID } from "@supatype/types"
785
+
786
+ type Status = "draft" | "published" | "archived"
787
+
788
+ export type Post = Model<{ id: UUID; status: Status }>
789
+ `,
790
+ "utf8",
791
+ )
792
+
793
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
794
+ expect(ast?.models[0]?.fields["status"]).toMatchObject({
795
+ kind: "enum",
796
+ values: ["draft", "published", "archived"],
797
+ })
798
+ })
799
+
800
+ it("resolves import renames of @supatype/types primitives", () => {
801
+ const dir = mkdtempSync(join(tmpdir(), "supatype-import-rename-"))
802
+ dirs.push(dir)
803
+ const schemaPath = join(dir, "schema.ts")
804
+ writeFileSync(
805
+ schemaPath,
806
+ `
807
+ import type { Model, UUID, Email, Optional as Maybe } from "@supatype/types"
808
+
809
+ export type User = Model<{ id: UUID; email: Maybe<Email> }>
810
+ `,
811
+ "utf8",
812
+ )
813
+
814
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
815
+ const user = ast?.models.find((m) => m.name === "User")
816
+ expect(user?.fields["email"]).toMatchObject({
817
+ kind: "email",
818
+ required: false,
819
+ })
820
+ })
821
+
822
+ it("resolves cross-file type aliases via local import", () => {
823
+ const dir = mkdtempSync(join(tmpdir(), "supatype-cross-file-alias-"))
824
+ dirs.push(dir)
825
+ writeFileSync(
826
+ join(dir, "field-types.ts"),
827
+ `
828
+ import type { Optional } from "@supatype/types"
829
+
830
+ export type Nullable<T> = Optional<T>
831
+ `,
832
+ "utf8",
833
+ )
834
+ const schemaPath = join(dir, "schema.ts")
835
+ writeFileSync(
836
+ schemaPath,
837
+ `
838
+ import type { Model, UUID, Email } from "@supatype/types"
839
+ import type { Nullable } from "./field-types"
840
+
841
+ export type User = Model<{ id: UUID; email: Nullable<Email> }>
842
+ `,
843
+ "utf8",
844
+ )
845
+
846
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
847
+ const user = ast?.models.find((m) => m.name === "User")
848
+ expect(user?.fields["email"]).toMatchObject({
849
+ kind: "email",
850
+ required: false,
851
+ })
852
+ })
853
+
854
+ it("resolves import rename of a local type alias", () => {
855
+ const dir = mkdtempSync(join(tmpdir(), "supatype-rename-local-alias-"))
856
+ dirs.push(dir)
857
+ writeFileSync(
858
+ join(dir, "field-types.ts"),
859
+ `
860
+ import type { Optional } from "@supatype/types"
861
+
862
+ export type Nullable<T> = Optional<T>
863
+ `,
864
+ "utf8",
865
+ )
866
+ const schemaPath = join(dir, "schema.ts")
867
+ writeFileSync(
868
+ schemaPath,
869
+ `
870
+ import type { Model, UUID, Email } from "@supatype/types"
871
+ import type { Nullable as MaybeNull } from "./field-types"
872
+
873
+ export type User = Model<{ id: UUID; email: MaybeNull<Email> }>
874
+ `,
875
+ "utf8",
876
+ )
877
+
878
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
879
+ const user = ast?.models.find((m) => m.name === "User")
880
+ expect(user?.fields["email"]).toMatchObject({
881
+ kind: "email",
882
+ required: false,
883
+ })
884
+ })
885
+
886
+ it("resolves conditional type aliases via type checker", () => {
887
+ const dir = mkdtempSync(join(tmpdir(), "supatype-conditional-alias-"))
888
+ dirs.push(dir)
889
+ const schemaPath = join(dir, "schema.ts")
890
+ writeFileSync(
891
+ schemaPath,
892
+ `
893
+ import type { Model, UUID, Email, Optional } from "@supatype/types"
894
+
895
+ type NullableStr<T> = T extends string ? Optional<T> : T
896
+
897
+ export type User = Model<{ id: UUID; email: NullableStr<Email> }>
898
+ `,
899
+ "utf8",
900
+ )
901
+
902
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
903
+ const user = ast?.models.find((m) => m.name === "User")
904
+ expect(user?.fields["email"]).toMatchObject({
905
+ kind: "email",
906
+ required: false,
907
+ })
908
+ })
909
+
910
+ it("resolves mapped type aliases as Model fields argument", () => {
911
+ const dir = mkdtempSync(join(tmpdir(), "supatype-mapped-fields-"))
912
+ dirs.push(dir)
913
+ const schemaPath = join(dir, "schema.ts")
914
+ writeFileSync(
915
+ schemaPath,
916
+ `
917
+ import type { Model, UUID, Email, Optional } from "@supatype/types"
918
+
919
+ type AllOptional<T> = { [K in keyof T]: Optional<T[K]> }
920
+
921
+ export type User = Model<AllOptional<{ email: Email; name: string }>>
922
+ `,
923
+ "utf8",
924
+ )
925
+
926
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
927
+ const user = ast?.models.find((m) => m.name === "User")
928
+ expect(user?.fields["email"]).toMatchObject({ kind: "email", required: false })
929
+ expect(user?.fields["name"]).toMatchObject({ kind: "text", required: false })
930
+ })
931
+
932
+ it("throws on unknown Supatype types instead of silently mapping to TEXT", () => {
933
+ const dir = mkdtempSync(join(tmpdir(), "supatype-unknown-type-"))
934
+ dirs.push(dir)
935
+ const schemaPath = join(dir, "schema.ts")
936
+ writeFileSync(
937
+ schemaPath,
938
+ `
939
+ import type { Model, UUID } from "@supatype/types"
940
+
941
+ export type User = Model<{ id: UUID; email: SomeType }>
942
+ `,
943
+ "utf8",
944
+ )
945
+
946
+ expect(() => extractSchemaAstFromTypes(schemaPath, dir)).toThrow(/Unknown Supatype type "SomeType"/)
947
+ })
948
+
949
+ it("throws on circular type alias chains", () => {
950
+ const dir = mkdtempSync(join(tmpdir(), "supatype-circular-alias-"))
951
+ dirs.push(dir)
952
+ const schemaPath = join(dir, "schema.ts")
953
+ writeFileSync(
954
+ schemaPath,
955
+ `
956
+ import type { Model, UUID } from "@supatype/types"
957
+
958
+ type A = B
959
+ type B = A
960
+
961
+ export type User = Model<{ id: UUID; email: A }>
962
+ `,
963
+ "utf8",
964
+ )
965
+
966
+ expect(() => extractSchemaAstFromTypes(schemaPath, dir)).toThrow(/circular alias chain/)
967
+ })
968
+
969
+ it("throws on TypeScript utility types used as field types", () => {
970
+ const dir = mkdtempSync(join(tmpdir(), "supatype-utility-type-"))
971
+ dirs.push(dir)
972
+ const schemaPath = join(dir, "schema.ts")
973
+ writeFileSync(
974
+ schemaPath,
975
+ `
976
+ import type { Model, UUID } from "@supatype/types"
977
+
978
+ export type User = Model<{ id: UUID; email: NonNullable<string> }>
979
+ `,
980
+ "utf8",
981
+ )
982
+
983
+ expect(() => extractSchemaAstFromTypes(schemaPath, dir)).toThrow(/Unknown Supatype type "NonNullable"/)
984
+ })
401
985
  })