@supatype/cli 0.1.0-alpha.6 → 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 (350) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +208 -1
  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/app-config.d.ts +7 -0
  9. package/dist/app-config.d.ts.map +1 -0
  10. package/dist/app-config.js +113 -0
  11. package/dist/app-config.js.map +1 -0
  12. package/dist/augmentation-generator.d.ts +2 -0
  13. package/dist/augmentation-generator.d.ts.map +1 -0
  14. package/dist/augmentation-generator.js +111 -0
  15. package/dist/augmentation-generator.js.map +1 -0
  16. package/dist/binary-cache.d.ts +94 -0
  17. package/dist/binary-cache.d.ts.map +1 -0
  18. package/dist/binary-cache.js +669 -0
  19. package/dist/binary-cache.js.map +1 -0
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +13 -7
  22. package/dist/cli.js.map +1 -1
  23. package/dist/commands/admin.d.ts.map +1 -1
  24. package/dist/commands/admin.js +4 -3
  25. package/dist/commands/admin.js.map +1 -1
  26. package/dist/commands/app.d.ts.map +1 -1
  27. package/dist/commands/app.js +56 -209
  28. package/dist/commands/app.js.map +1 -1
  29. package/dist/commands/cache.d.ts +6 -0
  30. package/dist/commands/cache.d.ts.map +1 -0
  31. package/dist/commands/cache.js +105 -0
  32. package/dist/commands/cache.js.map +1 -0
  33. package/dist/commands/cloud.d.ts +20 -0
  34. package/dist/commands/cloud.d.ts.map +1 -1
  35. package/dist/commands/cloud.js +50 -52
  36. package/dist/commands/cloud.js.map +1 -1
  37. package/dist/commands/db.d.ts.map +1 -1
  38. package/dist/commands/db.js +47 -54
  39. package/dist/commands/db.js.map +1 -1
  40. package/dist/commands/deploy.d.ts +2 -1
  41. package/dist/commands/deploy.d.ts.map +1 -1
  42. package/dist/commands/deploy.js +79 -52
  43. package/dist/commands/deploy.js.map +1 -1
  44. package/dist/commands/dev.d.ts +11 -0
  45. package/dist/commands/dev.d.ts.map +1 -1
  46. package/dist/commands/dev.js +759 -385
  47. package/dist/commands/dev.js.map +1 -1
  48. package/dist/commands/diff.d.ts.map +1 -1
  49. package/dist/commands/diff.js +30 -15
  50. package/dist/commands/diff.js.map +1 -1
  51. package/dist/commands/engine.d.ts +1 -3
  52. package/dist/commands/engine.d.ts.map +1 -1
  53. package/dist/commands/engine.js +13 -85
  54. package/dist/commands/engine.js.map +1 -1
  55. package/dist/commands/functions.d.ts.map +1 -1
  56. package/dist/commands/functions.js +92 -105
  57. package/dist/commands/functions.js.map +1 -1
  58. package/dist/commands/generate.d.ts.map +1 -1
  59. package/dist/commands/generate.js +22 -12
  60. package/dist/commands/generate.js.map +1 -1
  61. package/dist/commands/init.d.ts +1 -1
  62. package/dist/commands/init.d.ts.map +1 -1
  63. package/dist/commands/init.js +137 -410
  64. package/dist/commands/init.js.map +1 -1
  65. package/dist/commands/migrate-from-v1.d.ts +5 -0
  66. package/dist/commands/migrate-from-v1.d.ts.map +1 -0
  67. package/dist/commands/migrate-from-v1.js +125 -0
  68. package/dist/commands/migrate-from-v1.js.map +1 -0
  69. package/dist/commands/migrate.d.ts.map +1 -1
  70. package/dist/commands/migrate.js +27 -23
  71. package/dist/commands/migrate.js.map +1 -1
  72. package/dist/commands/pg.d.ts +8 -0
  73. package/dist/commands/pg.d.ts.map +1 -0
  74. package/dist/commands/pg.js +102 -0
  75. package/dist/commands/pg.js.map +1 -0
  76. package/dist/commands/pull.d.ts.map +1 -1
  77. package/dist/commands/pull.js +5 -66
  78. package/dist/commands/pull.js.map +1 -1
  79. package/dist/commands/push.d.ts.map +1 -1
  80. package/dist/commands/push.js +128 -38
  81. package/dist/commands/push.js.map +1 -1
  82. package/dist/commands/seed.d.ts +2 -0
  83. package/dist/commands/seed.d.ts.map +1 -1
  84. package/dist/commands/seed.js +44 -11
  85. package/dist/commands/seed.js.map +1 -1
  86. package/dist/commands/self-host.d.ts +7 -1
  87. package/dist/commands/self-host.d.ts.map +1 -1
  88. package/dist/commands/self-host.js +272 -758
  89. package/dist/commands/self-host.js.map +1 -1
  90. package/dist/commands/self-update.d.ts +9 -0
  91. package/dist/commands/self-update.d.ts.map +1 -0
  92. package/dist/commands/self-update.js +33 -0
  93. package/dist/commands/self-update.js.map +1 -0
  94. package/dist/commands/status.d.ts.map +1 -1
  95. package/dist/commands/status.js +4 -3
  96. package/dist/commands/status.js.map +1 -1
  97. package/dist/commands/types.d.ts +3 -0
  98. package/dist/commands/types.d.ts.map +1 -0
  99. package/dist/commands/types.js +62 -0
  100. package/dist/commands/types.js.map +1 -0
  101. package/dist/commands/update.d.ts +7 -0
  102. package/dist/commands/update.d.ts.map +1 -0
  103. package/dist/commands/update.js +93 -0
  104. package/dist/commands/update.js.map +1 -0
  105. package/dist/components.d.ts +5 -0
  106. package/dist/components.d.ts.map +1 -0
  107. package/dist/components.js +3 -0
  108. package/dist/components.js.map +1 -0
  109. package/dist/config.d.ts +10 -51
  110. package/dist/config.d.ts.map +1 -1
  111. package/dist/config.js +101 -33
  112. package/dist/config.js.map +1 -1
  113. package/dist/dev-compose.d.ts +17 -0
  114. package/dist/dev-compose.d.ts.map +1 -0
  115. package/dist/dev-compose.js +374 -0
  116. package/dist/dev-compose.js.map +1 -0
  117. package/dist/diff-output.d.ts +4 -0
  118. package/dist/diff-output.d.ts.map +1 -0
  119. package/dist/diff-output.js +12 -0
  120. package/dist/diff-output.js.map +1 -0
  121. package/dist/docker-postgres.d.ts +57 -0
  122. package/dist/docker-postgres.d.ts.map +1 -0
  123. package/dist/docker-postgres.js +208 -0
  124. package/dist/docker-postgres.js.map +1 -0
  125. package/dist/engine-client.d.ts +69 -0
  126. package/dist/engine-client.d.ts.map +1 -0
  127. package/dist/engine-client.js +157 -0
  128. package/dist/engine-client.js.map +1 -0
  129. package/dist/ensure-binary.d.ts +7 -0
  130. package/dist/ensure-binary.d.ts.map +1 -0
  131. package/dist/ensure-binary.js +17 -0
  132. package/dist/ensure-binary.js.map +1 -0
  133. package/dist/functions-router-gen.d.ts +14 -0
  134. package/dist/functions-router-gen.d.ts.map +1 -0
  135. package/dist/functions-router-gen.js +199 -0
  136. package/dist/functions-router-gen.js.map +1 -0
  137. package/dist/index.d.ts +4 -5
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +2 -3
  140. package/dist/index.js.map +1 -1
  141. package/dist/kong-config.d.ts +25 -0
  142. package/dist/kong-config.d.ts.map +1 -0
  143. package/dist/kong-config.js +71 -0
  144. package/dist/kong-config.js.map +1 -0
  145. package/dist/local-gateway.d.ts +7 -0
  146. package/dist/local-gateway.d.ts.map +1 -0
  147. package/dist/local-gateway.js +9 -0
  148. package/dist/local-gateway.js.map +1 -0
  149. package/dist/local-storage.d.ts +8 -0
  150. package/dist/local-storage.d.ts.map +1 -0
  151. package/dist/local-storage.js +14 -0
  152. package/dist/local-storage.js.map +1 -0
  153. package/dist/pgbouncer-userlist.d.ts +5 -0
  154. package/dist/pgbouncer-userlist.d.ts.map +1 -0
  155. package/dist/pgbouncer-userlist.js +14 -0
  156. package/dist/pgbouncer-userlist.js.map +1 -0
  157. package/dist/postgres-ctl.d.ts +44 -0
  158. package/dist/postgres-ctl.d.ts.map +1 -0
  159. package/dist/postgres-ctl.js +137 -0
  160. package/dist/postgres-ctl.js.map +1 -0
  161. package/dist/process-manager.d.ts +43 -0
  162. package/dist/process-manager.d.ts.map +1 -0
  163. package/dist/process-manager.js +135 -0
  164. package/dist/process-manager.js.map +1 -0
  165. package/dist/project-config.d.ts +235 -0
  166. package/dist/project-config.d.ts.map +1 -0
  167. package/dist/project-config.js +160 -0
  168. package/dist/project-config.js.map +1 -0
  169. package/dist/pull-utils.d.ts +15 -0
  170. package/dist/pull-utils.d.ts.map +1 -1
  171. package/dist/pull-utils.js +12 -0
  172. package/dist/pull-utils.js.map +1 -1
  173. package/dist/release-pins.d.ts +7 -0
  174. package/dist/release-pins.d.ts.map +1 -0
  175. package/dist/release-pins.js +27 -0
  176. package/dist/release-pins.js.map +1 -0
  177. package/dist/release-public-key.d.ts +8 -0
  178. package/dist/release-public-key.d.ts.map +1 -0
  179. package/dist/release-public-key.js +13 -0
  180. package/dist/release-public-key.js.map +1 -0
  181. package/dist/runtime-routes.d.ts +34 -0
  182. package/dist/runtime-routes.d.ts.map +1 -0
  183. package/dist/runtime-routes.js +252 -0
  184. package/dist/runtime-routes.js.map +1 -0
  185. package/dist/schema-ast-v2.d.ts +127 -0
  186. package/dist/schema-ast-v2.d.ts.map +1 -0
  187. package/dist/schema-ast-v2.js +226 -0
  188. package/dist/schema-ast-v2.js.map +1 -0
  189. package/dist/scripts/postinstall.d.ts +5 -6
  190. package/dist/scripts/postinstall.d.ts.map +1 -1
  191. package/dist/scripts/postinstall.js +36 -20
  192. package/dist/scripts/postinstall.js.map +1 -1
  193. package/dist/self-host-compose.d.ts +22 -0
  194. package/dist/self-host-compose.d.ts.map +1 -0
  195. package/dist/self-host-compose.js +347 -0
  196. package/dist/self-host-compose.js.map +1 -0
  197. package/dist/storage-provision.d.ts +24 -0
  198. package/dist/storage-provision.d.ts.map +1 -0
  199. package/dist/storage-provision.js +44 -0
  200. package/dist/storage-provision.js.map +1 -0
  201. package/dist/studio-admin-roles.d.ts +7 -0
  202. package/dist/studio-admin-roles.d.ts.map +1 -0
  203. package/dist/studio-admin-roles.js +14 -0
  204. package/dist/studio-admin-roles.js.map +1 -0
  205. package/dist/studio-dev-server.d.ts +22 -0
  206. package/dist/studio-dev-server.d.ts.map +1 -0
  207. package/dist/studio-dev-server.js +28 -0
  208. package/dist/studio-dev-server.js.map +1 -0
  209. package/dist/systemd.d.ts +26 -0
  210. package/dist/systemd.d.ts.map +1 -0
  211. package/dist/systemd.js +102 -0
  212. package/dist/systemd.js.map +1 -0
  213. package/dist/tsx-runner.d.ts.map +1 -1
  214. package/dist/tsx-runner.js +9 -2
  215. package/dist/tsx-runner.js.map +1 -1
  216. package/dist/type-extractor.d.ts +4 -0
  217. package/dist/type-extractor.d.ts.map +1 -0
  218. package/dist/type-extractor.js +1213 -0
  219. package/dist/type-extractor.js.map +1 -0
  220. package/dist/type-resolver.d.ts +33 -0
  221. package/dist/type-resolver.d.ts.map +1 -0
  222. package/dist/type-resolver.js +338 -0
  223. package/dist/type-resolver.js.map +1 -0
  224. package/package.json +4 -3
  225. package/releases/deno/VERSION +1 -0
  226. package/scripts/mirror-deno-release.sh +76 -0
  227. package/src/TYPE-RESOLUTION.md +294 -0
  228. package/src/app/proxy-dev-app.ts +67 -0
  229. package/src/app-config.ts +128 -0
  230. package/src/augmentation-generator.ts +126 -0
  231. package/src/binary-cache.ts +822 -0
  232. package/src/cli.ts +13 -8
  233. package/src/commands/admin.ts +4 -3
  234. package/src/commands/app.ts +67 -231
  235. package/src/commands/cache.ts +117 -0
  236. package/src/commands/cloud.ts +63 -64
  237. package/src/commands/db.ts +54 -63
  238. package/src/commands/deploy.ts +96 -62
  239. package/src/commands/dev.ts +933 -405
  240. package/src/commands/diff.ts +31 -29
  241. package/src/commands/engine.ts +13 -116
  242. package/src/commands/functions.ts +97 -115
  243. package/src/commands/generate.ts +23 -10
  244. package/src/commands/init.ts +149 -414
  245. package/src/commands/migrate-from-v1.ts +131 -0
  246. package/src/commands/migrate.ts +27 -23
  247. package/src/commands/pg.ts +133 -0
  248. package/src/commands/pull.ts +6 -85
  249. package/src/commands/push.ts +161 -56
  250. package/src/commands/seed.ts +54 -12
  251. package/src/commands/self-host.ts +312 -880
  252. package/src/commands/self-update.ts +45 -0
  253. package/src/commands/status.ts +4 -3
  254. package/src/commands/types.ts +76 -0
  255. package/src/commands/update.ts +109 -0
  256. package/src/components.ts +6 -0
  257. package/src/config.ts +127 -94
  258. package/src/dev-compose.ts +455 -0
  259. package/src/diff-output.ts +12 -0
  260. package/src/docker-postgres.ts +295 -0
  261. package/src/engine-client.ts +236 -0
  262. package/src/ensure-binary.ts +28 -0
  263. package/src/functions-router-gen.ts +224 -0
  264. package/src/index.ts +4 -12
  265. package/src/kong-config.ts +93 -0
  266. package/src/local-gateway.ts +9 -0
  267. package/src/local-storage.ts +14 -0
  268. package/src/pgbouncer-userlist.ts +15 -0
  269. package/src/postgres-ctl.ts +171 -0
  270. package/src/process-manager.ts +168 -0
  271. package/src/project-config.ts +386 -0
  272. package/src/pull-utils.ts +24 -0
  273. package/src/release-pins.ts +31 -0
  274. package/src/release-public-key.ts +12 -0
  275. package/src/runtime-routes.ts +291 -0
  276. package/src/schema-ast-v2.ts +324 -0
  277. package/src/scripts/postinstall.ts +36 -25
  278. package/src/self-host-compose.ts +389 -0
  279. package/src/storage-provision.ts +58 -0
  280. package/src/studio-admin-roles.ts +16 -0
  281. package/src/studio-dev-server.ts +53 -0
  282. package/src/systemd.ts +137 -0
  283. package/src/tsx-runner.ts +11 -1
  284. package/src/type-extractor.ts +1479 -0
  285. package/src/type-resolver.ts +457 -0
  286. package/tests/app-command.test.ts +54 -0
  287. package/tests/augmentation-generator.test.ts +59 -0
  288. package/tests/binary-cache-cloud-overrides.test.ts +123 -0
  289. package/tests/cached-artifact-format.test.ts +84 -0
  290. package/tests/cli-help.test.ts +40 -14
  291. package/tests/config.test.ts +171 -37
  292. package/tests/docker-postgres.test.ts +39 -0
  293. package/tests/engine-distribution.test.ts +3 -3
  294. package/tests/ensure-binary.test.ts +59 -0
  295. package/tests/init.test.ts +28 -86
  296. package/tests/migrate-from-v1.test.ts +29 -0
  297. package/tests/normalize-admin-config.test.ts +48 -0
  298. package/tests/pg-spawn-env.test.ts +18 -0
  299. package/tests/postgres-archive-tag.test.ts +9 -0
  300. package/tests/proxy-dev-app.test.ts +33 -0
  301. package/tests/pull-utils.test.ts +36 -1
  302. package/tests/release-pins.test.ts +28 -0
  303. package/tests/runtime-contract.test.ts +351 -0
  304. package/tests/seed-discover.test.ts +31 -0
  305. package/tests/studio-admin-roles.test.ts +27 -0
  306. package/tests/tsconfig.json +9 -0
  307. package/tests/type-extractor.test.ts +985 -0
  308. package/tests/type-resolver.test.ts +59 -0
  309. package/tsconfig.tsbuildinfo +1 -1
  310. package/vitest.config.ts +12 -0
  311. package/dist/engine/cache.d.ts +0 -37
  312. package/dist/engine/cache.d.ts.map +0 -1
  313. package/dist/engine/cache.js +0 -121
  314. package/dist/engine/cache.js.map +0 -1
  315. package/dist/engine/download.d.ts +0 -19
  316. package/dist/engine/download.d.ts.map +0 -1
  317. package/dist/engine/download.js +0 -108
  318. package/dist/engine/download.js.map +0 -1
  319. package/dist/engine/platform.d.ts +0 -24
  320. package/dist/engine/platform.d.ts.map +0 -1
  321. package/dist/engine/platform.js +0 -50
  322. package/dist/engine/platform.js.map +0 -1
  323. package/dist/engine/resolve.d.ts +0 -37
  324. package/dist/engine/resolve.d.ts.map +0 -1
  325. package/dist/engine/resolve.js +0 -133
  326. package/dist/engine/resolve.js.map +0 -1
  327. package/dist/engine/update-notify.d.ts +0 -11
  328. package/dist/engine/update-notify.d.ts.map +0 -1
  329. package/dist/engine/update-notify.js +0 -43
  330. package/dist/engine/update-notify.js.map +0 -1
  331. package/dist/engine/verify.d.ts +0 -50
  332. package/dist/engine/verify.d.ts.map +0 -1
  333. package/dist/engine/verify.js +0 -161
  334. package/dist/engine/verify.js.map +0 -1
  335. package/dist/engine-version.d.ts +0 -35
  336. package/dist/engine-version.d.ts.map +0 -1
  337. package/dist/engine-version.js +0 -35
  338. package/dist/engine-version.js.map +0 -1
  339. package/dist/engine.d.ts +0 -34
  340. package/dist/engine.d.ts.map +0 -1
  341. package/dist/engine.js +0 -76
  342. package/dist/engine.js.map +0 -1
  343. package/src/engine/cache.ts +0 -135
  344. package/src/engine/download.ts +0 -143
  345. package/src/engine/platform.ts +0 -66
  346. package/src/engine/resolve.ts +0 -197
  347. package/src/engine/update-notify.ts +0 -50
  348. package/src/engine/verify.ts +0 -206
  349. package/src/engine-version.ts +0 -39
  350. package/src/engine.ts +0 -99
@@ -0,0 +1,1479 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, isAbsolute, resolve } from "node:path"
3
+ import ts from "typescript"
4
+ import {
5
+ applyImportRename,
6
+ createResolveContext,
7
+ needsChecker,
8
+ resolveTypeNode,
9
+ tryResolveTypeReference,
10
+ unknownTypeError,
11
+ type ResolveContext,
12
+ } from "./type-resolver.js"
13
+
14
+ import {
15
+ emitField,
16
+ emitModel,
17
+ emitSchema,
18
+ defaultPgTypeForKind,
19
+ scalar,
20
+ type BlockDefinitionAst,
21
+ type ExtractedSchemaAstV2,
22
+ type ExtractedStorageBucketAst,
23
+ type FieldAstV2,
24
+ type ParsedField,
25
+ } from "./schema-ast-v2.js"
26
+
27
+ export type { ExtractedSchemaAstV2 as ExtractedSchemaAst, ExtractedStorageBucketAst } from "./schema-ast-v2.js"
28
+
29
+ interface FieldParseContext {
30
+ autoLocalize?: boolean
31
+ }
32
+
33
+ export function extractSchemaAstFromTypes(
34
+ schemaPath: string,
35
+ cwd: string = process.cwd(),
36
+ ): ExtractedSchemaAstV2 | null {
37
+ const absPath = resolve(cwd, schemaPath)
38
+ if (!existsSync(absPath)) {
39
+ throw new Error(`Schema file not found: ${absPath}`)
40
+ }
41
+
42
+ const sourceFiles = loadSchemaSourceFiles(absPath)
43
+ const resolveCtx = createResolveContext(sourceFiles)
44
+ const bucketAliases = new Map<string, string>()
45
+ const bucketsById = new Map<string, ExtractedStorageBucketAst>()
46
+ for (const sourceFile of sourceFiles) {
47
+ const bucketContext = collectBucketContext(sourceFile)
48
+ for (const [alias, bucketId] of bucketContext.aliases) {
49
+ bucketAliases.set(alias, bucketId)
50
+ }
51
+ for (const [bucketId, bucket] of bucketContext.bucketsById) {
52
+ const existing = bucketsById.get(bucketId)
53
+ if (existing !== undefined && !bucketsEqual(existing, bucket)) {
54
+ throw new Error(
55
+ `Conflicting Bucket<> declarations for id "${bucketId}". Use a single export per bucket id.`,
56
+ )
57
+ }
58
+ bucketsById.set(bucketId, bucket)
59
+ }
60
+ }
61
+
62
+ const blockAliases = new Map<string, BlockDefinitionAst>()
63
+ for (const sourceFile of sourceFiles) {
64
+ const next = collectBlockAliases(sourceFile, bucketAliases, bucketsById, resolveCtx)
65
+ for (const [name, block] of next) {
66
+ blockAliases.set(name, block)
67
+ }
68
+ }
69
+
70
+ const models: ExtractedSchemaAstV2["models"] = []
71
+
72
+ for (const sourceFile of sourceFiles) {
73
+ for (const stmt of sourceFile.statements) {
74
+ if (!ts.isTypeAliasDeclaration(stmt)) continue
75
+ if (!hasExportModifier(stmt)) continue
76
+ if (!ts.isTypeReferenceNode(stmt.type)) continue
77
+ const modelTypeName = stmt.type.typeName.getText(sourceFile)
78
+ if (modelTypeName !== "Model" && modelTypeName !== "LocalizedModel") continue
79
+ const [fieldsArg, metaArg] = stmt.type.typeArguments ?? []
80
+ if (!fieldsArg) continue
81
+ const fieldsLiteral = unwrapModelFields(fieldsArg, sourceFile, resolveCtx)
82
+ if (!fieldsLiteral) continue
83
+
84
+ const metaHints = parseMetaLiteral(metaArg, sourceFile)
85
+ const fieldContext: FieldParseContext = {
86
+ autoLocalize: modelTypeName === "LocalizedModel" || metaHints.autoLocalize === true,
87
+ }
88
+
89
+ const fields: Record<string, FieldAstV2> = {}
90
+ for (const member of fieldsLiteral.members) {
91
+ if (!ts.isPropertySignature(member) || !member.type) continue
92
+ const name = getPropertyName(member.name)
93
+ if (!name) continue
94
+ fields[name] = parseFieldType(
95
+ name,
96
+ member.type,
97
+ sourceFile,
98
+ blockAliases,
99
+ bucketAliases,
100
+ bucketsById,
101
+ fieldContext,
102
+ resolveCtx,
103
+ )
104
+ }
105
+
106
+ const { tableName, access, options } = parseModelMeta(
107
+ metaArg,
108
+ sourceFile,
109
+ stmt.name.text,
110
+ fieldsArg,
111
+ fields,
112
+ )
113
+
114
+ models.push(
115
+ emitModel(stmt.name.text, fields, options, tableName, access),
116
+ )
117
+ }
118
+ }
119
+
120
+ if (models.length === 0) return null
121
+
122
+ const storageBuckets =
123
+ bucketsById.size > 0 ? [...bucketsById.values()].sort((a, b) => a.id.localeCompare(b.id)) : undefined
124
+
125
+ let localeConfig: { locales: string[]; defaultLocale: string } | undefined
126
+ for (const sourceFile of sourceFiles) {
127
+ const found = collectLocaleConfig(sourceFile)
128
+ if (!found) continue
129
+ if (localeConfig !== undefined) {
130
+ throw new Error(
131
+ "Conflicting LocaleConfig declarations. Export at most one `localeConfig` type alias.",
132
+ )
133
+ }
134
+ localeConfig = found
135
+ }
136
+
137
+ return emitSchema(models, {
138
+ ...(storageBuckets !== undefined && storageBuckets.length > 0 && { storageBuckets }),
139
+ ...(localeConfig !== undefined && {
140
+ locales: localeConfig.locales,
141
+ defaultLocale: localeConfig.defaultLocale,
142
+ }),
143
+ })
144
+ }
145
+
146
+ function loadSchemaSourceFiles(entryPath: string): ts.SourceFile[] {
147
+ const visited = new Set<string>()
148
+ const sourceFiles: ts.SourceFile[] = []
149
+ const queue: string[] = [entryPath]
150
+
151
+ while (queue.length > 0) {
152
+ const currentPath = queue.shift()
153
+ if (!currentPath) continue
154
+ if (visited.has(currentPath)) continue
155
+ visited.add(currentPath)
156
+
157
+ if (!existsSync(currentPath)) continue
158
+ const sourceText = readFileSync(currentPath, "utf8")
159
+ const sourceFile = ts.createSourceFile(currentPath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
160
+ sourceFiles.push(sourceFile)
161
+
162
+ const baseDir = dirname(currentPath)
163
+ for (const stmt of sourceFile.statements) {
164
+ let specifier: string | undefined
165
+ if (ts.isExportDeclaration(stmt)) {
166
+ if (!stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
167
+ specifier = stmt.moduleSpecifier.text
168
+ } else if (ts.isImportDeclaration(stmt)) {
169
+ if (!stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
170
+ specifier = stmt.moduleSpecifier.text
171
+ } else {
172
+ continue
173
+ }
174
+ if (!specifier.startsWith(".")) continue
175
+ const nextPath = resolveTypeModulePath(baseDir, specifier)
176
+ if (!nextPath) continue
177
+ if (!visited.has(nextPath)) queue.push(nextPath)
178
+ }
179
+ }
180
+
181
+ return sourceFiles
182
+ }
183
+
184
+ function resolveTypeModulePath(fromDir: string, specifier: string): string | null {
185
+ const basePath = isAbsolute(specifier) ? specifier : resolve(fromDir, specifier)
186
+ const candidates = specifier.endsWith(".js")
187
+ ? [
188
+ basePath,
189
+ basePath.replace(/\.js$/i, ".ts"),
190
+ basePath.replace(/\.js$/i, ".tsx"),
191
+ basePath.replace(/\.js$/i, ".d.ts"),
192
+ ]
193
+ : [
194
+ basePath,
195
+ `${basePath}.ts`,
196
+ `${basePath}.tsx`,
197
+ `${basePath}.d.ts`,
198
+ resolve(basePath, "index.ts"),
199
+ resolve(basePath, "index.tsx"),
200
+ resolve(basePath, "index.d.ts"),
201
+ ]
202
+
203
+ for (const candidate of candidates) {
204
+ if (existsSync(candidate)) return candidate
205
+ }
206
+ return null
207
+ }
208
+
209
+ function hasExportModifier(node: ts.Node): boolean {
210
+ if (!ts.canHaveModifiers(node)) return false
211
+ return (ts.getModifiers(node)?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword) ?? false)
212
+ }
213
+
214
+ function getPropertyName(name: ts.PropertyName): string | null {
215
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text
216
+ return null
217
+ }
218
+
219
+ function unwrapModelFields(
220
+ typeNode: ts.TypeNode,
221
+ sourceFile: ts.SourceFile,
222
+ resolveCtx: ResolveContext,
223
+ depth = 0,
224
+ ): ts.TypeLiteralNode | null {
225
+ if (depth > 16) return null
226
+ if (ts.isTypeLiteralNode(typeNode)) return typeNode
227
+
228
+ if (needsChecker(typeNode)) {
229
+ const resolved = resolveTypeNode(typeNode, sourceFile, resolveCtx)
230
+ if (ts.isTypeLiteralNode(resolved)) return resolved
231
+ return unwrapModelFields(resolved, sourceFile, resolveCtx, depth + 1)
232
+ }
233
+
234
+ if (!ts.isTypeReferenceNode(typeNode) || !ts.isIdentifier(typeNode.typeName)) return null
235
+
236
+ const typeName = applyImportRename(typeNode.typeName.text, sourceFile, resolveCtx.renameMap)
237
+
238
+ // Composite helpers in @supatype/types wrap the concrete field object.
239
+ if (
240
+ typeName === "WithTimestamps" ||
241
+ typeName === "WithSoftDelete" ||
242
+ typeName === "WithPublishable"
243
+ ) {
244
+ const inner = typeNode.typeArguments?.[0]
245
+ if (!inner) return null
246
+ return unwrapModelFields(inner, sourceFile, resolveCtx, depth + 1)
247
+ }
248
+
249
+ const expanded = tryResolveTypeReference(typeNode, sourceFile, resolveCtx)
250
+ if (expanded) {
251
+ if (ts.isTypeLiteralNode(expanded)) return expanded
252
+ return unwrapModelFields(expanded, sourceFile, resolveCtx, depth + 1)
253
+ }
254
+
255
+ return null
256
+ }
257
+
258
+ /** Parse `Default<T, V>` second type argument into a JSON-serializable literal. */
259
+ function parseDefaultLiteral(
260
+ node: ts.TypeNode,
261
+ sourceFile: ts.SourceFile,
262
+ ): string | number | boolean | null | undefined {
263
+ if (ts.isLiteralTypeNode(node)) {
264
+ const lit = node.literal
265
+ if (ts.isStringLiteral(lit) || ts.isNoSubstitutionTemplateLiteral(lit)) return lit.text
266
+ if (ts.isNumericLiteral(lit)) return Number(lit.text)
267
+ if (lit.kind === ts.SyntaxKind.TrueKeyword) return true
268
+ if (lit.kind === ts.SyntaxKind.FalseKeyword) return false
269
+ if (lit.kind === ts.SyntaxKind.NullKeyword) return null
270
+ }
271
+ if (node.kind === ts.SyntaxKind.TrueKeyword) return true
272
+ if (node.kind === ts.SyntaxKind.FalseKeyword) return false
273
+ if (node.kind === ts.SyntaxKind.NullKeyword) return null
274
+ // Negative numeric literals appear as PrefixUnaryExpression in some TS versions.
275
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) {
276
+ const inner = parseDefaultLiteral(node.operand as unknown as ts.TypeNode, sourceFile)
277
+ if (typeof inner === "number") return -inner
278
+ }
279
+ return undefined
280
+ }
281
+
282
+ function parseFieldType(
283
+ fieldName: string,
284
+ typeNode: ts.TypeNode,
285
+ sourceFile: ts.SourceFile,
286
+ blockAliases: Map<string, BlockDefinitionAst>,
287
+ bucketAliases: Map<string, string>,
288
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
289
+ context: FieldParseContext = {},
290
+ resolveCtx: ResolveContext,
291
+ ): FieldAstV2 {
292
+ const flags = {
293
+ required: true,
294
+ unique: false,
295
+ index: false,
296
+ primaryKey: false,
297
+ serverGenerated: false,
298
+ autoIncrement: false,
299
+ relationCardinality: undefined as "one" | "many" | undefined,
300
+ relationTarget: undefined as string | undefined,
301
+ editorReadOnly: false,
302
+ computedFromSources: undefined as string[] | undefined,
303
+ computedFromTemplate: undefined as string | undefined,
304
+ fieldDefault: undefined as string | number | boolean | null | undefined,
305
+ localized: false,
306
+ notLocalized: false,
307
+ }
308
+
309
+ const resolving = new Set<string>()
310
+ let current = typeNode
311
+ while (ts.isTypeReferenceNode(current) && ts.isIdentifier(current.typeName)) {
312
+ const typeName = applyImportRename(current.typeName.text, sourceFile, resolveCtx.renameMap)
313
+ switch (typeName) {
314
+ case "Optional":
315
+ flags.required = false
316
+ current = current.typeArguments?.[0] ?? current
317
+ continue
318
+ case "Unique":
319
+ flags.unique = true
320
+ current = current.typeArguments?.[0] ?? current
321
+ continue
322
+ case "Indexed":
323
+ flags.index = true
324
+ current = current.typeArguments?.[0] ?? current
325
+ continue
326
+ case "ServerDefault":
327
+ flags.serverGenerated = true
328
+ current = current.typeArguments?.[0] ?? current
329
+ continue
330
+ case "AutoIncrement":
331
+ flags.serverGenerated = true
332
+ flags.autoIncrement = true
333
+ current = current.typeArguments?.[0] ?? current
334
+ continue
335
+ case "PrimaryKey":
336
+ flags.primaryKey = true
337
+ flags.required = true
338
+ flags.unique = true
339
+ current = current.typeArguments?.[0] ?? current
340
+ continue
341
+ case "Default": {
342
+ const valueArg = current.typeArguments?.[1]
343
+ if (valueArg !== undefined) {
344
+ const literal = parseDefaultLiteral(valueArg, sourceFile)
345
+ if (literal !== undefined) {
346
+ flags.fieldDefault = literal
347
+ }
348
+ }
349
+ // Unwrap to T so `Default<boolean, true>` resolves as boolean, not text.
350
+ current = current.typeArguments?.[0] ?? current
351
+ continue
352
+ }
353
+ case "Searchable":
354
+ current = current.typeArguments?.[0] ?? current
355
+ continue
356
+ case "EditorReadOnly":
357
+ flags.editorReadOnly = true
358
+ current = current.typeArguments?.[0] ?? current
359
+ continue
360
+ case "Computed":
361
+ flags.editorReadOnly = true
362
+ flags.serverGenerated = true
363
+ current = current.typeArguments?.[0] ?? current
364
+ continue
365
+ case "ComputedFrom": {
366
+ const valueArg = current.typeArguments?.[0]
367
+ const sourcesArg = current.typeArguments?.[1]
368
+ const parsed = parseComputedFromSecondArg(sourcesArg, sourceFile)
369
+ if (parsed) {
370
+ flags.computedFromSources = parsed.sources
371
+ flags.computedFromTemplate = parsed.template
372
+ } else {
373
+ flags.computedFromSources = ["title"]
374
+ }
375
+ current = valueArg ?? current
376
+ continue
377
+ }
378
+ case "MaxLength":
379
+ case "MinLength":
380
+ case "Between":
381
+ current = current.typeArguments?.[0] ?? current
382
+ continue
383
+ case "Localized":
384
+ flags.localized = true
385
+ current = current.typeArguments?.[0] ?? current
386
+ continue
387
+ case "NotLocalized":
388
+ flags.notLocalized = true
389
+ current = current.typeArguments?.[0] ?? current
390
+ continue
391
+ case "RelatedTo":
392
+ flags.relationCardinality = "one"
393
+ flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
394
+ // `target` must match `ModelAst.name` to satisfy validator resolution.
395
+ // FK column follows the field name (two relations to the same model need distinct columns).
396
+ return emitField({
397
+ kind: "relation",
398
+ kernel: { cardinality: "belongsTo", target: flags.relationTarget! },
399
+ db: { foreignKey: relationForeignKeyFromField(fieldName) },
400
+ platform: flags.editorReadOnly ? { readOnly: true } : {},
401
+ })
402
+ case "HasOne":
403
+ flags.relationCardinality = "one"
404
+ flags.relationTarget = current.typeArguments?.[0]?.getText(sourceFile).replace(/\W/g, "") ?? "unknown"
405
+ return emitField({
406
+ kind: "relation",
407
+ kernel: { cardinality: "hasOne", target: flags.relationTarget },
408
+ db: {},
409
+ platform: flags.editorReadOnly ? { readOnly: true } : {},
410
+ })
411
+ case "HasMany":
412
+ case "ManyToMany":
413
+ flags.relationCardinality = "many"
414
+ flags.relationTarget = current.typeArguments?.[0]?.getText(sourceFile).replace(/\W/g, "") ?? "unknown"
415
+ return emitField({
416
+ kind: "relation",
417
+ kernel: { cardinality: "hasMany", target: flags.relationTarget },
418
+ db: {},
419
+ platform: flags.editorReadOnly ? { readOnly: true } : {},
420
+ })
421
+ default: {
422
+ const resolved = tryResolveTypeReference(current, sourceFile, resolveCtx, { fieldName, resolving })
423
+ if (resolved) {
424
+ current = resolved
425
+ continue
426
+ }
427
+ break
428
+ }
429
+ }
430
+ break
431
+ }
432
+
433
+ const scalarBase = parseScalarType(
434
+ current,
435
+ sourceFile,
436
+ blockAliases,
437
+ bucketAliases,
438
+ bucketsById,
439
+ context,
440
+ resolveCtx,
441
+ fieldName,
442
+ resolving,
443
+ )
444
+
445
+ let parsed: ParsedField = {
446
+ kind: scalarBase.kind,
447
+ kernel: {
448
+ ...scalarBase.kernel,
449
+ required: flags.required,
450
+ ...(flags.primaryKey && { primaryKey: true }),
451
+ },
452
+ db: {
453
+ ...scalarBase.db,
454
+ unique: flags.unique,
455
+ index: flags.index,
456
+ },
457
+ platform: {
458
+ ...scalarBase.platform,
459
+ ...(flags.editorReadOnly && { readOnly: true }),
460
+ },
461
+ }
462
+
463
+ if (flags.autoIncrement && parsed.kind === "integer") {
464
+ parsed = { ...parsed, kind: "serial", db: { ...parsed.db, pgType: "SERIAL" } }
465
+ }
466
+
467
+ if (fieldName === "id" && parsed.kind === "uuid" && flags.primaryKey === false) {
468
+ parsed = {
469
+ ...parsed,
470
+ kernel: { ...parsed.kernel, primaryKey: true, required: true },
471
+ db: { ...parsed.db, unique: true },
472
+ }
473
+ }
474
+
475
+ if (flags.fieldDefault !== undefined) {
476
+ if (parsed.kernel.default !== undefined) {
477
+ throw new Error(
478
+ `Field "${fieldName}": use either Default<…> or an inline type default (e.g. RichText<"…">), not both.`,
479
+ )
480
+ }
481
+ parsed = {
482
+ ...parsed,
483
+ kernel: { ...parsed.kernel, default: { kind: "value", value: flags.fieldDefault } },
484
+ }
485
+ }
486
+
487
+ if (parsed.kernel.primaryKey === true && parsed.kind === "uuid" && parsed.kernel.default === undefined) {
488
+ parsed = {
489
+ ...parsed,
490
+ kernel: { ...parsed.kernel, default: { kind: "genRandomUuid" } },
491
+ }
492
+ } else if (
493
+ parsed.kernel.primaryKey === true &&
494
+ (parsed.kind === "serial" || parsed.kind === "bigSerial")
495
+ ) {
496
+ flags.serverGenerated = true
497
+ }
498
+
499
+ if (flags.serverGenerated === true) {
500
+ parsed = { ...parsed, db: { ...parsed.db, serverGenerated: true } }
501
+ }
502
+
503
+ const auditTs =
504
+ fieldName === "created_at" ||
505
+ fieldName === "updated_at" ||
506
+ fieldName === "createdAt" ||
507
+ fieldName === "updatedAt"
508
+ if (auditTs) {
509
+ parsed = { ...parsed, db: { ...parsed.db, serverGenerated: true } }
510
+ if (
511
+ (parsed.kind === "datetime" || parsed.kind === "date") &&
512
+ parsed.kernel.default === undefined
513
+ ) {
514
+ parsed = { ...parsed, kernel: { ...parsed.kernel, default: { kind: "now" } } }
515
+ }
516
+ }
517
+
518
+ if (
519
+ flags.serverGenerated &&
520
+ (parsed.kind === "datetime" || parsed.kind === "date") &&
521
+ parsed.kernel.default === undefined
522
+ ) {
523
+ parsed = { ...parsed, kernel: { ...parsed.kernel, default: { kind: "now" } } }
524
+ }
525
+
526
+ const hasCfTemplate = flags.computedFromTemplate !== undefined
527
+ const hasCfSources = Boolean(flags.computedFromSources && flags.computedFromSources.length > 0)
528
+ if (parsed.kind === "text" && (hasCfTemplate || hasCfSources)) {
529
+ const kernel: ParsedField["kernel"] = { ...parsed.kernel }
530
+ if (hasCfSources && flags.computedFromSources) {
531
+ kernel.sources = flags.computedFromSources
532
+ }
533
+ if (hasCfTemplate && flags.computedFromTemplate !== undefined) {
534
+ kernel.template = flags.computedFromTemplate
535
+ }
536
+ parsed = { ...parsed, kernel }
537
+ }
538
+
539
+ return emitField(finalizeParsedField(parsed, flags, context))
540
+ }
541
+
542
+ function finalizeParsedField(
543
+ parsed: ParsedField,
544
+ flags: { localized: boolean; notLocalized: boolean },
545
+ context: FieldParseContext,
546
+ ): ParsedField {
547
+ let localized = flags.localized
548
+
549
+ if (
550
+ !localized &&
551
+ !flags.notLocalized &&
552
+ context.autoLocalize &&
553
+ shouldAutoLocalizeFieldKind(parsed.kind)
554
+ ) {
555
+ localized = true
556
+ }
557
+
558
+ if (parsed.kind === "blocks" && parsed.kernel.blocks && context.autoLocalize && !localized) {
559
+ return {
560
+ ...parsed,
561
+ kernel: {
562
+ ...parsed.kernel,
563
+ blocks: parsed.kernel.blocks.map((blockDef) => ({
564
+ ...blockDef,
565
+ fields: Object.fromEntries(
566
+ Object.entries(blockDef.fields).map(([name, fieldWire]) => [
567
+ name,
568
+ localizeFieldWire(fieldWire),
569
+ ]),
570
+ ),
571
+ })),
572
+ },
573
+ }
574
+ }
575
+
576
+ if (localized) {
577
+ return {
578
+ ...parsed,
579
+ kernel: { ...parsed.kernel, localized: true },
580
+ db: { ...parsed.db, pgType: "JSONB" },
581
+ }
582
+ }
583
+ return parsed
584
+ }
585
+
586
+ function shouldAutoLocalizeFieldKind(kind: unknown): boolean {
587
+ return kind === "text" || kind === "richText"
588
+ }
589
+
590
+ function localizeFieldWire(field: FieldAstV2): FieldAstV2 {
591
+ if (field.localized === true) return field
592
+ if (!shouldAutoLocalizeFieldKind(field.kind)) return field
593
+ const annotations = (field.annotations ?? {}) as { db?: Record<string, unknown>; platform?: Record<string, unknown> }
594
+ return {
595
+ ...field,
596
+ localized: true,
597
+ annotations: {
598
+ ...annotations,
599
+ db: { ...annotations.db, pgType: "JSONB" },
600
+ },
601
+ }
602
+ }
603
+
604
+ function parseScalarType(
605
+ typeNode: ts.TypeNode,
606
+ sourceFile: ts.SourceFile,
607
+ blockAliases: Map<string, BlockDefinitionAst>,
608
+ bucketAliases: Map<string, string>,
609
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
610
+ context: FieldParseContext = {},
611
+ resolveCtx: ResolveContext,
612
+ fieldName = "?",
613
+ resolving: Set<string> = new Set(),
614
+ ): ParsedField {
615
+ if (ts.isArrayTypeNode(typeNode)) {
616
+ const element = parseScalarType(
617
+ typeNode.elementType,
618
+ sourceFile,
619
+ blockAliases,
620
+ bucketAliases,
621
+ bucketsById,
622
+ context,
623
+ resolveCtx,
624
+ fieldName,
625
+ resolving,
626
+ )
627
+ return scalar("array", {
628
+ db: { elementType: defaultPgTypeForKind(element.kind) },
629
+ })
630
+ }
631
+
632
+ if (ts.isUnionTypeNode(typeNode)) {
633
+ const literals = typeNode.types.filter(ts.isLiteralTypeNode)
634
+ if (literals.length === typeNode.types.length && literals.every((lit) => ts.isStringLiteral(lit.literal))) {
635
+ return scalar("enum", {
636
+ kernel: {
637
+ values: literals.map((lit) => (lit.literal as ts.StringLiteral).text),
638
+ },
639
+ })
640
+ }
641
+ const nonNull = typeNode.types.find((t) => t.kind !== ts.SyntaxKind.NullKeyword)
642
+ if (nonNull) {
643
+ return parseScalarType(
644
+ nonNull,
645
+ sourceFile,
646
+ blockAliases,
647
+ bucketAliases,
648
+ bucketsById,
649
+ context,
650
+ resolveCtx,
651
+ fieldName,
652
+ resolving,
653
+ )
654
+ }
655
+ }
656
+
657
+ if (ts.isTypeReferenceNode(typeNode) && ts.isIdentifier(typeNode.typeName)) {
658
+ const resolved = tryResolveTypeReference(typeNode, sourceFile, resolveCtx, { fieldName, resolving })
659
+ if (resolved) {
660
+ return parseScalarType(
661
+ resolved,
662
+ sourceFile,
663
+ blockAliases,
664
+ bucketAliases,
665
+ bucketsById,
666
+ context,
667
+ resolveCtx,
668
+ fieldName,
669
+ resolving,
670
+ )
671
+ }
672
+ }
673
+
674
+ if (ts.isTypeReferenceNode(typeNode)) {
675
+ const ref = ts.isIdentifier(typeNode.typeName)
676
+ ? applyImportRename(typeNode.typeName.text, sourceFile, resolveCtx.renameMap)
677
+ : typeNode.typeName.getText(sourceFile)
678
+ switch (ref) {
679
+ case "UUID":
680
+ case "SupatypeAuthUserId":
681
+ return scalar("uuid")
682
+ case "RichText": {
683
+ const defaultArg = typeNode.typeArguments?.[0]
684
+ if (!defaultArg) return scalar("richText")
685
+ const literal = parseDefaultLiteral(defaultArg, sourceFile)
686
+ if (literal === undefined) {
687
+ throw new Error(
688
+ `RichText default must be a string literal (plain text or Lexical JSON string), not HTML.`,
689
+ )
690
+ }
691
+ if (typeof literal !== "string") {
692
+ throw new Error(
693
+ `RichText<…> default must be a string literal (plain text or Lexical JSON string).`,
694
+ )
695
+ }
696
+ return scalar("richText", {
697
+ kernel: { default: { kind: "value", value: literal } },
698
+ })
699
+ }
700
+ case "Slug": {
701
+ const fromArg = typeNode.typeArguments?.[0]
702
+ const fromLiteral = fromArg ? literalStringType(fromArg) : null
703
+ return scalar("slug", { kernel: { from: fromLiteral ?? "title" } })
704
+ }
705
+ case "Email":
706
+ return scalar("email")
707
+ case "URL":
708
+ return scalar("url")
709
+ case "Markdown":
710
+ case "PhoneNumber":
711
+ return scalar("text")
712
+ case "Color":
713
+ return scalar("color")
714
+ case "IPAddress":
715
+ return scalar("ip")
716
+ case "CIDR":
717
+ return scalar("cidr")
718
+ case "MacAddress":
719
+ return scalar("macaddr")
720
+ case "XML":
721
+ return scalar("xml")
722
+ case "TSQuery":
723
+ return scalar("tsQuery")
724
+ case "TSVector":
725
+ return scalar("tsVector")
726
+ case "Money":
727
+ return scalar("money")
728
+ case "Decimal":
729
+ return scalar("decimal")
730
+ case "DateOnly":
731
+ return scalar("date")
732
+ case "Date":
733
+ case "DateTime":
734
+ case "Timestamp":
735
+ return scalar("datetime", { db: { pgType: "TIMESTAMP WITH TIME ZONE" } })
736
+ case "Int":
737
+ return scalar("integer")
738
+ case "SmallInt":
739
+ return scalar("smallInt")
740
+ case "BigInt":
741
+ return scalar("bigInt")
742
+ case "Float":
743
+ return scalar("float")
744
+ case "Bytea":
745
+ return scalar("bytes")
746
+ case "JSON":
747
+ return scalar("json")
748
+ case "Button":
749
+ return scalar("button", { db: { pgType: "JSONB" } })
750
+ case "Duration":
751
+ return scalar("json", { db: { pgType: "JSONB" } })
752
+ case "GeoPoint":
753
+ case "Geo":
754
+ return scalar("geo", { kernel: { geoType: "point", srid: 4326 } })
755
+ case "Asset":
756
+ case "FileAsset": {
757
+ const bucket = resolveBucketName(typeNode.typeArguments?.[0], sourceFile, bucketAliases, "assets")
758
+ const assetOpts = parseAssetFieldOptions(typeNode.typeArguments?.[1], sourceFile)
759
+ return attachStorageFieldMeta(
760
+ scalar("file", {
761
+ db: { pgType: "TEXT" },
762
+ kernel: { bucket, ...(assetOpts.localized && { localized: true }) },
763
+ }),
764
+ bucket,
765
+ bucketsById,
766
+ )
767
+ }
768
+ case "ImageAsset": {
769
+ const bucket = resolveBucketName(typeNode.typeArguments?.[0], sourceFile, bucketAliases, "images")
770
+ const assetOpts = parseAssetFieldOptions(typeNode.typeArguments?.[1], sourceFile)
771
+ return attachStorageFieldMeta(
772
+ scalar("image", {
773
+ db: { pgType: "TEXT" },
774
+ kernel: { bucket, ...(assetOpts.localized && { localized: true }) },
775
+ }),
776
+ bucket,
777
+ bucketsById,
778
+ )
779
+ }
780
+ case "Blocks":
781
+ return scalar("blocks", {
782
+ kernel: {
783
+ index: true,
784
+ blocks: parseBlocksTypeDefinitions(
785
+ typeNode.typeArguments?.[0],
786
+ sourceFile,
787
+ blockAliases,
788
+ bucketAliases,
789
+ bucketsById,
790
+ context,
791
+ resolveCtx,
792
+ ),
793
+ },
794
+ })
795
+ case "Vector": {
796
+ const dimensions = typeNode.typeArguments?.[0]?.getText(sourceFile)
797
+ return scalar("vector", {
798
+ kernel: { dimensions: Number(dimensions ?? "1536") },
799
+ })
800
+ }
801
+ default:
802
+ throw unknownTypeError(ref, fieldName)
803
+ }
804
+ }
805
+
806
+ switch (typeNode.kind) {
807
+ case ts.SyntaxKind.StringKeyword:
808
+ return scalar("text")
809
+ case ts.SyntaxKind.NumberKeyword:
810
+ return scalar("float")
811
+ case ts.SyntaxKind.BooleanKeyword:
812
+ return scalar("boolean")
813
+ default:
814
+ return scalar("json")
815
+ }
816
+ }
817
+
818
+ function collectBlockAliases(
819
+ sourceFile: ts.SourceFile,
820
+ bucketAliases: Map<string, string>,
821
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
822
+ resolveCtx: ResolveContext,
823
+ ): Map<string, BlockDefinitionAst> {
824
+ const blocks = new Map<string, BlockDefinitionAst>()
825
+ for (const stmt of sourceFile.statements) {
826
+ if (!ts.isTypeAliasDeclaration(stmt)) continue
827
+ if (!ts.isTypeReferenceNode(stmt.type)) continue
828
+ if (!ts.isIdentifier(stmt.type.typeName) || stmt.type.typeName.text !== "Block") continue
829
+ const block = parseInlineBlockDefinition(
830
+ stmt.type,
831
+ sourceFile,
832
+ new Map(),
833
+ bucketAliases,
834
+ bucketsById,
835
+ {},
836
+ resolveCtx,
837
+ )
838
+ if (!block) continue
839
+ blocks.set(stmt.name.text, block)
840
+ }
841
+ return blocks
842
+ }
843
+
844
+ function collectLocaleConfig(
845
+ sourceFile: ts.SourceFile,
846
+ ): { locales: string[]; defaultLocale: string } | undefined {
847
+ for (const stmt of sourceFile.statements) {
848
+ if (!ts.isTypeAliasDeclaration(stmt)) continue
849
+ if (!hasExportModifier(stmt)) continue
850
+ if (!ts.isTypeReferenceNode(stmt.type)) continue
851
+ if (stmt.type.typeName.getText(sourceFile) !== "LocaleConfig") continue
852
+ const parsed = parseLocaleConfigTypeRef(stmt.type, sourceFile)
853
+ if (parsed) return parsed
854
+ }
855
+ return undefined
856
+ }
857
+
858
+ function parseLocaleConfigTypeRef(
859
+ typeRef: ts.TypeReferenceNode,
860
+ sourceFile: ts.SourceFile,
861
+ ): { locales: string[]; defaultLocale: string } | null {
862
+ const [localesArg, defaultArg] = typeRef.typeArguments ?? []
863
+ if (!localesArg || !defaultArg) return null
864
+
865
+ const locales = parseStringLiteralTuple(localesArg, sourceFile)
866
+ const defaultLocale = literalStringType(defaultArg)
867
+ if (!locales || locales.length === 0 || !defaultLocale) return null
868
+ if (!locales.includes(defaultLocale)) {
869
+ throw new Error(
870
+ `LocaleConfig defaultLocale "${defaultLocale}" must be one of: ${locales.join(", ")}`,
871
+ )
872
+ }
873
+ return { locales, defaultLocale }
874
+ }
875
+
876
+ function parseStringLiteralTuple(node: ts.TypeNode, sourceFile: ts.SourceFile): string[] | null {
877
+ if (!ts.isTupleTypeNode(node)) return null
878
+ const out: string[] = []
879
+ for (const el of node.elements) {
880
+ const lit = literalStringType(el)
881
+ if (!lit) return null
882
+ out.push(lit)
883
+ }
884
+ return out
885
+ }
886
+
887
+ function collectBucketContext(sourceFile: ts.SourceFile): {
888
+ aliases: Map<string, string>
889
+ bucketsById: Map<string, ExtractedStorageBucketAst>
890
+ } {
891
+ const aliases = new Map<string, string>()
892
+ const bucketsById = new Map<string, ExtractedStorageBucketAst>()
893
+
894
+ for (const stmt of sourceFile.statements) {
895
+ if (!ts.isTypeAliasDeclaration(stmt)) continue
896
+ if (!ts.isTypeReferenceNode(stmt.type)) continue
897
+ if (!ts.isIdentifier(stmt.type.typeName) || stmt.type.typeName.text !== "Bucket") continue
898
+ const [nameArg, configArg] = stmt.type.typeArguments ?? []
899
+ if (!nameArg || !ts.isLiteralTypeNode(nameArg) || !ts.isStringLiteral(nameArg.literal)) continue
900
+ const id = nameArg.literal.text
901
+ aliases.set(stmt.name.text, id)
902
+
903
+ const parsed =
904
+ configArg && ts.isTypeLiteralNode(configArg)
905
+ ? parseBucketTypeLiteral(configArg, sourceFile)
906
+ : {}
907
+
908
+ const next = buildExtractedBucketAst(id, parsed)
909
+ const existing = bucketsById.get(id)
910
+ if (existing !== undefined && !bucketsEqual(existing, next)) {
911
+ throw new Error(
912
+ `Conflicting Bucket<> declarations for id "${id}". Use a single export per bucket id.`,
913
+ )
914
+ }
915
+ bucketsById.set(id, next)
916
+ }
917
+
918
+ return { aliases, bucketsById }
919
+ }
920
+
921
+ function buildExtractedBucketAst(
922
+ id: string,
923
+ parsed: Partial<ParsedBucketLiteral>,
924
+ ): ExtractedStorageBucketAst {
925
+ const mode = parsed.accessMode ?? "private"
926
+ const pub = mode === "public"
927
+
928
+ const row: ExtractedStorageBucketAst = {
929
+ id,
930
+ public: pub,
931
+ accessMode: mode,
932
+ ...(parsed.allowedMimeTypes !== undefined && parsed.allowedMimeTypes.length > 0
933
+ ? { allowedMimeTypes: parsed.allowedMimeTypes }
934
+ : {}),
935
+ ...(parsed.fileSizeLimit !== undefined ? { fileSizeLimit: parsed.fileSizeLimit } : {}),
936
+ ...(parsed.access !== undefined &&
937
+ Object.keys(parsed.access).length > 0 && { access: parsed.access }),
938
+ ...(parsed.s3BucketPolicy !== undefined ? { s3BucketPolicy: parsed.s3BucketPolicy } : {}),
939
+ }
940
+ return row
941
+ }
942
+
943
+ interface ParsedBucketLiteral {
944
+ accessMode?: "public" | "private" | "custom"
945
+ allowedMimeTypes?: string[]
946
+ fileSizeLimit?: number
947
+ access?: Record<string, unknown>
948
+ s3BucketPolicy?: string
949
+ }
950
+
951
+ function parseBucketTypeLiteral(
952
+ lit: ts.TypeLiteralNode,
953
+ sourceFile: ts.SourceFile,
954
+ ): Partial<ParsedBucketLiteral> {
955
+ const out: Partial<ParsedBucketLiteral> = {}
956
+ for (const member of lit.members) {
957
+ if (!ts.isPropertySignature(member) || !member.type) continue
958
+ const key = getPropertyName(member.name)
959
+ if (!key) continue
960
+
961
+ if (key === "accessMode") {
962
+ const mode = parseAccessModeLiteral(member.type, sourceFile)
963
+ if (mode !== undefined) out.accessMode = mode
964
+ continue
965
+ }
966
+ if (key === "maxSize") {
967
+ const s = parseSizeStringLiteral(member.type, sourceFile)
968
+ if (s !== undefined) {
969
+ const bytes = parseDataSizeBytes(s)
970
+ out.fileSizeLimit = bytes
971
+ }
972
+ continue
973
+ }
974
+ if (key === "accept") {
975
+ const types = parseMimeAcceptList(member.type, sourceFile)
976
+ if (types !== undefined) out.allowedMimeTypes = types
977
+ continue
978
+ }
979
+ if (key === "access") {
980
+ const acc = parsePartialBucketAccess(member.type, sourceFile)
981
+ if (acc !== undefined && Object.keys(acc).length > 0) out.access = acc
982
+ continue
983
+ }
984
+ if (key === "s3BucketPolicy") {
985
+ const pol = parseJsonStringLiteral(member.type, sourceFile)
986
+ if (pol !== undefined) out.s3BucketPolicy = pol
987
+ continue
988
+ }
989
+ }
990
+ return out
991
+ }
992
+
993
+ function parseAccessModeLiteral(
994
+ typeNode: ts.TypeNode,
995
+ sourceFile: ts.SourceFile,
996
+ ): "public" | "private" | "custom" | undefined {
997
+ const text = stripQuotes(typeNode.getText(sourceFile))
998
+ if (text === "public" || text === "private" || text === "custom") return text
999
+ return undefined
1000
+ }
1001
+
1002
+ function parseSizeStringLiteral(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): string | undefined {
1003
+ if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) {
1004
+ return typeNode.literal.text
1005
+ }
1006
+ return stripQuotes(typeNode.getText(sourceFile)) || undefined
1007
+ }
1008
+
1009
+ function parseJsonStringLiteral(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): string | undefined {
1010
+ return parseSizeStringLiteral(typeNode, sourceFile)
1011
+ }
1012
+
1013
+ function parseMimeAcceptList(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): string[] | undefined {
1014
+ if (ts.isTypeOperatorNode(typeNode) && typeNode.operator === ts.SyntaxKind.ReadonlyKeyword) {
1015
+ return parseMimeAcceptList(typeNode.type, sourceFile)
1016
+ }
1017
+ if (ts.isTupleTypeNode(typeNode)) {
1018
+ const values: string[] = []
1019
+ for (const el of typeNode.elements) {
1020
+ const node: ts.TypeNode = ts.isNamedTupleMember(el) ? el.type : el
1021
+ const s = literalStringType(node)
1022
+ if (!s) return undefined
1023
+ values.push(s)
1024
+ }
1025
+ return values.length > 0 ? values : undefined
1026
+ }
1027
+ if (ts.isUnionTypeNode(typeNode)) {
1028
+ const values: string[] = []
1029
+ for (const u of typeNode.types) {
1030
+ const s = literalStringType(u)
1031
+ if (!s) return undefined
1032
+ values.push(s)
1033
+ }
1034
+ return values.length > 0 ? values : undefined
1035
+ }
1036
+ return undefined
1037
+ }
1038
+
1039
+ function parsePartialBucketAccess(
1040
+ typeNode: ts.TypeNode,
1041
+ sourceFile: ts.SourceFile,
1042
+ ): Record<string, unknown> | undefined {
1043
+ if (!ts.isTypeLiteralNode(typeNode)) return undefined
1044
+ const access: Record<string, unknown> = {}
1045
+ for (const member of typeNode.members) {
1046
+ if (!ts.isPropertySignature(member) || !member.type) continue
1047
+ const key = getPropertyName(member.name)
1048
+ if (key !== "read" && key !== "create" && key !== "delete") continue
1049
+ access[key] = parseAccessRule(member.type, sourceFile)
1050
+ }
1051
+ return access
1052
+ }
1053
+
1054
+ /** Parse human-readable size from schema types, e.g. `50MB`. */
1055
+ function parseDataSizeBytes(lit: string): number {
1056
+ const m = lit.trim().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)$/i)
1057
+ if (!m?.[1] || !m[2]) throw new Error(`Invalid maxSize literal: "${lit}". Use forms like "50MB", "100KB".`)
1058
+ const n = Number(m[1])
1059
+ if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid maxSize number in: "${lit}"`)
1060
+ const pow: Record<string, number> = {
1061
+ B: 0,
1062
+ KB: 10,
1063
+ MB: 20,
1064
+ GB: 30,
1065
+ }
1066
+ const unit = m[2].toUpperCase() as keyof typeof pow
1067
+ const exp = pow[unit]
1068
+ if (exp === undefined) throw new Error(`Unsupported maxSize unit in: "${lit}"`)
1069
+ return Math.round(n * Math.pow(2, exp))
1070
+ }
1071
+
1072
+ function stripQuotes(s: string): string {
1073
+ return s.replace(/^['"]|['"]$/g, "")
1074
+ }
1075
+
1076
+ function bucketsEqual(a: ExtractedStorageBucketAst, b: ExtractedStorageBucketAst): boolean {
1077
+ return (
1078
+ a.public === b.public &&
1079
+ (a.accessMode ?? "private") === (b.accessMode ?? "private") &&
1080
+ JSON.stringify(a.access ?? null) === JSON.stringify(b.access ?? null) &&
1081
+ JSON.stringify(a.allowedMimeTypes ?? null) === JSON.stringify(b.allowedMimeTypes ?? null) &&
1082
+ (a.fileSizeLimit ?? null) === (b.fileSizeLimit ?? null) &&
1083
+ (a.s3BucketPolicy ?? null) === (b.s3BucketPolicy ?? null)
1084
+ )
1085
+ }
1086
+
1087
+ function attachStorageFieldMeta(
1088
+ field: ParsedField,
1089
+ bucketId: string,
1090
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
1091
+ ): ParsedField {
1092
+ const cfg = bucketsById.get(bucketId)
1093
+ if (cfg?.accessMode !== undefined) {
1094
+ return {
1095
+ ...field,
1096
+ kernel: { ...field.kernel, accessMode: cfg.accessMode },
1097
+ }
1098
+ }
1099
+ return field
1100
+ }
1101
+
1102
+ function parseBlocksTypeDefinitions(
1103
+ blocksArg: ts.TypeNode | undefined,
1104
+ sourceFile: ts.SourceFile,
1105
+ blockAliases: Map<string, BlockDefinitionAst>,
1106
+ bucketAliases: Map<string, string>,
1107
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
1108
+ context: FieldParseContext = {},
1109
+ resolveCtx: ResolveContext,
1110
+ ): BlockDefinitionAst[] {
1111
+ if (!blocksArg) return []
1112
+ const parts = ts.isUnionTypeNode(blocksArg) ? blocksArg.types : [blocksArg]
1113
+ const out: BlockDefinitionAst[] = []
1114
+ for (const part of parts) {
1115
+ if (ts.isTypeReferenceNode(part) && ts.isIdentifier(part.typeName)) {
1116
+ if (part.typeName.text === "Block") {
1117
+ const inline = parseInlineBlockDefinition(
1118
+ part,
1119
+ sourceFile,
1120
+ blockAliases,
1121
+ bucketAliases,
1122
+ bucketsById,
1123
+ context,
1124
+ resolveCtx,
1125
+ )
1126
+ if (inline) out.push(inline)
1127
+ continue
1128
+ }
1129
+ const aliased = blockAliases.get(part.typeName.text)
1130
+ if (aliased) out.push(aliased)
1131
+ }
1132
+ }
1133
+ return out
1134
+ }
1135
+
1136
+ function parseInlineBlockDefinition(
1137
+ ref: ts.TypeReferenceNode,
1138
+ sourceFile: ts.SourceFile,
1139
+ blockAliases: Map<string, BlockDefinitionAst>,
1140
+ bucketAliases: Map<string, string>,
1141
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
1142
+ context: FieldParseContext = {},
1143
+ resolveCtx: ResolveContext,
1144
+ ): BlockDefinitionAst | null {
1145
+ const [nameArg, fieldsArg, metaArg] = ref.typeArguments ?? []
1146
+ const name = literalStringType(nameArg)
1147
+ if (!name || !fieldsArg || !ts.isTypeLiteralNode(fieldsArg)) return null
1148
+
1149
+ const fields: Record<string, FieldAstV2> = {}
1150
+ for (const member of fieldsArg.members) {
1151
+ if (!ts.isPropertySignature(member) || !member.type) continue
1152
+ const fieldName = getPropertyName(member.name)
1153
+ if (!fieldName) continue
1154
+ fields[fieldName] = parseFieldType(
1155
+ fieldName,
1156
+ member.type,
1157
+ sourceFile,
1158
+ blockAliases,
1159
+ bucketAliases,
1160
+ bucketsById,
1161
+ context,
1162
+ resolveCtx,
1163
+ )
1164
+ }
1165
+
1166
+ let label: string | undefined
1167
+ let icon: string | undefined
1168
+ if (metaArg && ts.isTypeLiteralNode(metaArg)) {
1169
+ for (const m of metaArg.members) {
1170
+ if (!ts.isPropertySignature(m) || !m.type) continue
1171
+ const key = getPropertyName(m.name)
1172
+ if (!key) continue
1173
+ const value = literalStringType(m.type)
1174
+ if (!value) continue
1175
+ if (key === "label") label = value
1176
+ if (key === "icon") icon = value
1177
+ }
1178
+ }
1179
+
1180
+ return {
1181
+ name,
1182
+ ...(label !== undefined && { label }),
1183
+ ...(icon !== undefined && { icon }),
1184
+ fields,
1185
+ }
1186
+ }
1187
+
1188
+ function literalStringType(typeNode: ts.TypeNode | undefined): string | null {
1189
+ if (!typeNode) return null
1190
+ if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) return typeNode.literal.text
1191
+ return null
1192
+ }
1193
+
1194
+ /** Field names referenced in `{name}` and `{truncate(name, n)}` (case-sensitive, same as model fields). */
1195
+ function fieldNamesInComputedTemplate(template: string): string[] {
1196
+ const fields = new Set<string>()
1197
+ const reTrunc = /\{truncate\s*\(\s*([a-zA-Z_]\w*)\s*,\s*(\d+)\s*\)\}/gi
1198
+ let m: RegExpExecArray | null
1199
+ while ((m = reTrunc.exec(template)) !== null) {
1200
+ const ref = m[1]
1201
+ if (ref) fields.add(ref)
1202
+ }
1203
+ const reSimple = /\{([a-zA-Z_]\w*)\}/g
1204
+ while ((m = reSimple.exec(template)) !== null) {
1205
+ const ref = m[1]
1206
+ if (ref) fields.add(ref)
1207
+ }
1208
+ return [...fields]
1209
+ }
1210
+
1211
+ function looksLikeComputedTemplateLiteral(lit: string): boolean {
1212
+ return /\{truncate\s*\(/i.test(lit) || /\{[a-zA-Z_]\w*\}/g.test(lit)
1213
+ }
1214
+
1215
+ /** Resolves second type arg of `ComputedFrom<Value, Sources>` — tuple concat, single field, or template literal. */
1216
+ function parseComputedFromSecondArg(
1217
+ sourcesArg: ts.TypeNode | undefined,
1218
+ sourceFile: ts.SourceFile,
1219
+ ): { sources: string[]; template?: string } | null {
1220
+ if (!sourcesArg) return null
1221
+ const single = literalStringType(sourcesArg)
1222
+ if (single) {
1223
+ if (looksLikeComputedTemplateLiteral(single)) {
1224
+ return { sources: fieldNamesInComputedTemplate(single), template: single }
1225
+ }
1226
+ return { sources: [single] }
1227
+ }
1228
+
1229
+ const elemsFromTupleType = (tuple: ts.TupleTypeNode): ts.TypeNode[] | null => {
1230
+ const nodes: ts.TypeNode[] = []
1231
+ for (const el of tuple.elements) {
1232
+ if (ts.isNamedTupleMember(el)) {
1233
+ if (!el.type) return null
1234
+ nodes.push(el.type)
1235
+ continue
1236
+ }
1237
+ nodes.push(el as ts.TypeNode)
1238
+ }
1239
+ return nodes
1240
+ }
1241
+
1242
+ const tupleElems = (): ts.TypeNode[] | null => {
1243
+ if (ts.isTupleTypeNode(sourcesArg)) return elemsFromTupleType(sourcesArg)
1244
+ if (ts.isTypeOperatorNode(sourcesArg) && sourcesArg.operator === ts.SyntaxKind.ReadonlyKeyword) {
1245
+ const inner = sourcesArg.type
1246
+ if (inner && ts.isTupleTypeNode(inner)) return elemsFromTupleType(inner)
1247
+ }
1248
+ return null
1249
+ }
1250
+
1251
+ const elems = tupleElems()
1252
+ if (!elems || elems.length === 0) return null
1253
+ const keys: string[] = []
1254
+ for (const node of elems) {
1255
+ const k = literalStringType(node)
1256
+ if (!k) return null
1257
+ keys.push(k)
1258
+ }
1259
+ return { sources: keys }
1260
+ }
1261
+
1262
+ function resolveBucketName(
1263
+ typeArg: ts.TypeNode | undefined,
1264
+ sourceFile: ts.SourceFile,
1265
+ bucketAliases: Map<string, string>,
1266
+ fallback: string,
1267
+ ): string {
1268
+ if (!typeArg) return fallback
1269
+ if (ts.isTypeReferenceNode(typeArg) && ts.isIdentifier(typeArg.typeName)) {
1270
+ return bucketAliases.get(typeArg.typeName.text) ?? typeArg.typeName.text
1271
+ }
1272
+ if (ts.isLiteralTypeNode(typeArg) && ts.isStringLiteral(typeArg.literal)) {
1273
+ return typeArg.literal.text
1274
+ }
1275
+ return typeArg.getText(sourceFile).replace(/^['"]|['"]$/g, "") || fallback
1276
+ }
1277
+
1278
+ function isBooleanLiteralType(typeNode: ts.TypeNode, value: boolean): boolean {
1279
+ if (value) {
1280
+ if (typeNode.kind === ts.SyntaxKind.TrueKeyword) return true
1281
+ if (ts.isLiteralTypeNode(typeNode) && typeNode.literal.kind === ts.SyntaxKind.TrueKeyword) {
1282
+ return true
1283
+ }
1284
+ return false
1285
+ }
1286
+ if (typeNode.kind === ts.SyntaxKind.FalseKeyword) return true
1287
+ if (ts.isLiteralTypeNode(typeNode) && typeNode.literal.kind === ts.SyntaxKind.FalseKeyword) {
1288
+ return true
1289
+ }
1290
+ return false
1291
+ }
1292
+
1293
+ function parseAssetFieldOptions(
1294
+ optionsArg: ts.TypeNode | undefined,
1295
+ sourceFile: ts.SourceFile,
1296
+ ): { localized: boolean } {
1297
+ if (!optionsArg || !ts.isTypeLiteralNode(optionsArg)) return { localized: false }
1298
+ for (const member of optionsArg.members) {
1299
+ if (!ts.isPropertySignature(member) || !member.type) continue
1300
+ const key = getPropertyName(member.name)
1301
+ if (key === "localized" && isBooleanLiteralType(member.type, true)) {
1302
+ return { localized: true }
1303
+ }
1304
+ }
1305
+ return { localized: false }
1306
+ }
1307
+
1308
+ function parseMetaLiteral(
1309
+ metaArg: ts.TypeNode | undefined,
1310
+ sourceFile: ts.SourceFile,
1311
+ ): {
1312
+ tableName?: string
1313
+ singleton?: boolean
1314
+ timestamps?: boolean
1315
+ softDelete?: boolean
1316
+ autoLocalize?: boolean
1317
+ } {
1318
+ const result: {
1319
+ tableName?: string
1320
+ singleton?: boolean
1321
+ timestamps?: boolean
1322
+ softDelete?: boolean
1323
+ autoLocalize?: boolean
1324
+ } = {}
1325
+
1326
+ if (!metaArg || !ts.isTypeLiteralNode(metaArg)) return result
1327
+
1328
+ for (const member of metaArg.members) {
1329
+ if (!ts.isPropertySignature(member) || !member.type) continue
1330
+ const key = getPropertyName(member.name)
1331
+ if (!key) continue
1332
+
1333
+ if (key === "singleton" && isBooleanLiteralType(member.type, true)) {
1334
+ result.singleton = true
1335
+ } else if (key === "timestamps") {
1336
+ if (isBooleanLiteralType(member.type, true)) result.timestamps = true
1337
+ if (isBooleanLiteralType(member.type, false)) result.timestamps = false
1338
+ } else if (key === "softDelete") {
1339
+ if (isBooleanLiteralType(member.type, true)) result.softDelete = true
1340
+ if (isBooleanLiteralType(member.type, false)) result.softDelete = false
1341
+ } else if (key === "autoLocalize" && isBooleanLiteralType(member.type, true)) {
1342
+ result.autoLocalize = true
1343
+ } else if (
1344
+ key === "tableName" &&
1345
+ ts.isLiteralTypeNode(member.type) &&
1346
+ ts.isStringLiteral(member.type.literal)
1347
+ ) {
1348
+ result.tableName = member.type.literal.text
1349
+ }
1350
+ }
1351
+
1352
+ return result
1353
+ }
1354
+
1355
+ function hasCompositeWrapper(typeNode: ts.TypeNode, wrapperName: string): boolean {
1356
+ if (!ts.isTypeReferenceNode(typeNode) || !ts.isIdentifier(typeNode.typeName)) return false
1357
+ if (typeNode.typeName.text === wrapperName) return true
1358
+ if (
1359
+ typeNode.typeName.text === "WithTimestamps" ||
1360
+ typeNode.typeName.text === "WithSoftDelete" ||
1361
+ typeNode.typeName.text === "WithPublishable"
1362
+ ) {
1363
+ const inner = typeNode.typeArguments?.[0]
1364
+ if (inner) return hasCompositeWrapper(inner, wrapperName)
1365
+ }
1366
+ return false
1367
+ }
1368
+
1369
+ function parseModelMeta(
1370
+ metaArg: ts.TypeNode | undefined,
1371
+ sourceFile: ts.SourceFile,
1372
+ modelName: string,
1373
+ fieldsArg: ts.TypeNode,
1374
+ fields: Record<string, FieldAstV2>,
1375
+ ): { tableName: string; access: Record<string, unknown>; options: Record<string, unknown> } {
1376
+ const literal = parseMetaLiteral(metaArg, sourceFile)
1377
+ const singleton = literal.singleton === true
1378
+ const tableName =
1379
+ literal.tableName ?? (singleton ? `_global_${toSnakeCase(modelName)}` : toSnakeCase(modelName))
1380
+
1381
+ const timestamps =
1382
+ literal.timestamps ??
1383
+ (hasCompositeWrapper(fieldsArg, "WithTimestamps") ||
1384
+ (fields["created_at"] !== undefined && fields["updated_at"] !== undefined))
1385
+
1386
+ const softDelete =
1387
+ literal.softDelete ??
1388
+ (hasCompositeWrapper(fieldsArg, "WithSoftDelete") || fields["deleted_at"] !== undefined)
1389
+
1390
+ const options: Record<string, unknown> = {}
1391
+ if (singleton) options.singleton = true
1392
+ if (timestamps) options.timestamps = true
1393
+ if (softDelete) options.softDelete = true
1394
+ if (literal.autoLocalize === true) options.autoLocalize = true
1395
+
1396
+ return {
1397
+ tableName,
1398
+ access: parseModelAccess(metaArg, sourceFile),
1399
+ options,
1400
+ }
1401
+ }
1402
+
1403
+ function parseModelAccess(metaArg: ts.TypeNode | undefined, sourceFile: ts.SourceFile): Record<string, unknown> {
1404
+ if (!metaArg || !ts.isTypeLiteralNode(metaArg)) return {}
1405
+ const accessProp = metaArg.members.find(
1406
+ (member) => ts.isPropertySignature(member) && getPropertyName(member.name) === "access",
1407
+ )
1408
+ if (!accessProp || !ts.isPropertySignature(accessProp) || !accessProp.type || !ts.isTypeLiteralNode(accessProp.type)) {
1409
+ return {}
1410
+ }
1411
+
1412
+ const access: Record<string, unknown> = {}
1413
+ for (const member of accessProp.type.members) {
1414
+ if (!ts.isPropertySignature(member) || !member.type) continue
1415
+ const key = getPropertyName(member.name)
1416
+ if (!key) continue
1417
+ access[key] = parseAccessRule(member.type, sourceFile)
1418
+ }
1419
+ return access
1420
+ }
1421
+
1422
+ function parseAccessRule(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): Record<string, unknown> {
1423
+ if (!ts.isTypeReferenceNode(typeNode)) return { type: "private" }
1424
+ const ref = typeNode.typeName.getText(sourceFile)
1425
+ switch (ref) {
1426
+ case "Public":
1427
+ case "BucketPublic":
1428
+ return { type: "public" }
1429
+ case "LoggedIn":
1430
+ case "BucketLoggedIn":
1431
+ return { type: "authenticated" }
1432
+ case "Private":
1433
+ case "BucketPrivate":
1434
+ return { type: "private" }
1435
+ case "BucketOwner":
1436
+ return { type: "owner", field: "owner_id" }
1437
+ case "Owner": {
1438
+ const args = typeNode.typeArguments ?? []
1439
+ const keyArg = args.length >= 2 ? args[1] : args[0]
1440
+ // Must match engine `AccessRule::Owner { field }` (see supatype-schema-engine parser/ast.rs).
1441
+ return { type: "owner", field: keyArg?.getText(sourceFile).replace(/['"]/g, "") ?? "user_id" }
1442
+ }
1443
+ case "OwnerFrom": {
1444
+ const relationArg = typeNode.typeArguments?.[0]
1445
+ const relationField = relationArg?.getText(sourceFile).replace(/['"]/g, "") ?? "owner"
1446
+ return { type: "owner", field: relationField }
1447
+ }
1448
+ case "Role": {
1449
+ const roleArg = typeNode.typeArguments?.[0]
1450
+ return { type: "role", roles: [roleArg?.getText(sourceFile).replace(/['"]/g, "") ?? "admin"] }
1451
+ }
1452
+ case "BucketRole": {
1453
+ const roleArg = typeNode.typeArguments?.[0]
1454
+ return { type: "role", roles: [roleArg?.getText(sourceFile).replace(/['"]/g, "") ?? "admin"] }
1455
+ }
1456
+ default:
1457
+ return { type: "private" }
1458
+ }
1459
+ }
1460
+
1461
+ function relationTargetFromTypeArg(typeArg: ts.TypeNode | undefined, sourceFile: ts.SourceFile): string {
1462
+ if (!typeArg) return "unknown"
1463
+ const raw = typeArg.getText(sourceFile).replace(/\s/g, "")
1464
+ if (raw === "SupatypeAuthUser") return "supatype:user"
1465
+ return raw.replace(/\W/g, "")
1466
+ }
1467
+
1468
+ function toSnakeCase(s: string): string {
1469
+ return s.replace(/([A-Z])/g, "_$1").replace(/^_/, "").toLowerCase()
1470
+ }
1471
+
1472
+ function relationForeignKeyFromField(fieldName: string): string {
1473
+ const snake = fieldName
1474
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
1475
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
1476
+ .toLowerCase()
1477
+ const base = snake.replace(/_id$/i, "")
1478
+ return `${base}_id`
1479
+ }