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

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 (309) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +203 -1
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/app-config.d.ts +7 -0
  5. package/dist/app-config.d.ts.map +1 -0
  6. package/dist/app-config.js +113 -0
  7. package/dist/app-config.js.map +1 -0
  8. package/dist/augmentation-generator.d.ts +2 -0
  9. package/dist/augmentation-generator.d.ts.map +1 -0
  10. package/dist/augmentation-generator.js +111 -0
  11. package/dist/augmentation-generator.js.map +1 -0
  12. package/dist/binary-cache.d.ts +89 -0
  13. package/dist/binary-cache.d.ts.map +1 -0
  14. package/dist/binary-cache.js +656 -0
  15. package/dist/binary-cache.js.map +1 -0
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +13 -7
  18. package/dist/cli.js.map +1 -1
  19. package/dist/commands/admin.d.ts.map +1 -1
  20. package/dist/commands/admin.js +4 -3
  21. package/dist/commands/admin.js.map +1 -1
  22. package/dist/commands/app.d.ts.map +1 -1
  23. package/dist/commands/app.js +56 -209
  24. package/dist/commands/app.js.map +1 -1
  25. package/dist/commands/cache.d.ts +6 -0
  26. package/dist/commands/cache.d.ts.map +1 -0
  27. package/dist/commands/cache.js +105 -0
  28. package/dist/commands/cache.js.map +1 -0
  29. package/dist/commands/cloud.d.ts +12 -0
  30. package/dist/commands/cloud.d.ts.map +1 -1
  31. package/dist/commands/cloud.js +36 -46
  32. package/dist/commands/cloud.js.map +1 -1
  33. package/dist/commands/db.d.ts.map +1 -1
  34. package/dist/commands/db.js +47 -54
  35. package/dist/commands/db.js.map +1 -1
  36. package/dist/commands/deploy.d.ts +2 -1
  37. package/dist/commands/deploy.d.ts.map +1 -1
  38. package/dist/commands/deploy.js +92 -51
  39. package/dist/commands/deploy.js.map +1 -1
  40. package/dist/commands/dev.d.ts +11 -0
  41. package/dist/commands/dev.d.ts.map +1 -1
  42. package/dist/commands/dev.js +751 -384
  43. package/dist/commands/dev.js.map +1 -1
  44. package/dist/commands/diff.d.ts.map +1 -1
  45. package/dist/commands/diff.js +20 -15
  46. package/dist/commands/diff.js.map +1 -1
  47. package/dist/commands/engine.d.ts +1 -3
  48. package/dist/commands/engine.d.ts.map +1 -1
  49. package/dist/commands/engine.js +13 -85
  50. package/dist/commands/engine.js.map +1 -1
  51. package/dist/commands/functions.d.ts.map +1 -1
  52. package/dist/commands/functions.js +92 -105
  53. package/dist/commands/functions.js.map +1 -1
  54. package/dist/commands/generate.d.ts.map +1 -1
  55. package/dist/commands/generate.js +22 -12
  56. package/dist/commands/generate.js.map +1 -1
  57. package/dist/commands/init.d.ts +1 -1
  58. package/dist/commands/init.d.ts.map +1 -1
  59. package/dist/commands/init.js +124 -410
  60. package/dist/commands/init.js.map +1 -1
  61. package/dist/commands/migrate-from-v1.d.ts +5 -0
  62. package/dist/commands/migrate-from-v1.d.ts.map +1 -0
  63. package/dist/commands/migrate-from-v1.js +125 -0
  64. package/dist/commands/migrate-from-v1.js.map +1 -0
  65. package/dist/commands/migrate.d.ts.map +1 -1
  66. package/dist/commands/migrate.js +27 -23
  67. package/dist/commands/migrate.js.map +1 -1
  68. package/dist/commands/pg.d.ts +8 -0
  69. package/dist/commands/pg.d.ts.map +1 -0
  70. package/dist/commands/pg.js +102 -0
  71. package/dist/commands/pg.js.map +1 -0
  72. package/dist/commands/pull.d.ts.map +1 -1
  73. package/dist/commands/pull.js +5 -66
  74. package/dist/commands/pull.js.map +1 -1
  75. package/dist/commands/push.d.ts.map +1 -1
  76. package/dist/commands/push.js +99 -39
  77. package/dist/commands/push.js.map +1 -1
  78. package/dist/commands/seed.d.ts +2 -0
  79. package/dist/commands/seed.d.ts.map +1 -1
  80. package/dist/commands/seed.js +44 -11
  81. package/dist/commands/seed.js.map +1 -1
  82. package/dist/commands/self-host.d.ts +7 -1
  83. package/dist/commands/self-host.d.ts.map +1 -1
  84. package/dist/commands/self-host.js +272 -758
  85. package/dist/commands/self-host.js.map +1 -1
  86. package/dist/commands/self-update.d.ts +9 -0
  87. package/dist/commands/self-update.d.ts.map +1 -0
  88. package/dist/commands/self-update.js +33 -0
  89. package/dist/commands/self-update.js.map +1 -0
  90. package/dist/commands/status.d.ts.map +1 -1
  91. package/dist/commands/status.js +4 -3
  92. package/dist/commands/status.js.map +1 -1
  93. package/dist/commands/types.d.ts +3 -0
  94. package/dist/commands/types.d.ts.map +1 -0
  95. package/dist/commands/types.js +62 -0
  96. package/dist/commands/types.js.map +1 -0
  97. package/dist/commands/update.d.ts +7 -0
  98. package/dist/commands/update.d.ts.map +1 -0
  99. package/dist/commands/update.js +77 -0
  100. package/dist/commands/update.js.map +1 -0
  101. package/dist/components.d.ts +5 -0
  102. package/dist/components.d.ts.map +1 -0
  103. package/dist/components.js +3 -0
  104. package/dist/components.js.map +1 -0
  105. package/dist/config.d.ts +10 -51
  106. package/dist/config.d.ts.map +1 -1
  107. package/dist/config.js +101 -33
  108. package/dist/config.js.map +1 -1
  109. package/dist/docker-postgres.d.ts +39 -0
  110. package/dist/docker-postgres.d.ts.map +1 -0
  111. package/dist/docker-postgres.js +96 -0
  112. package/dist/docker-postgres.js.map +1 -0
  113. package/dist/engine-client.d.ts +67 -0
  114. package/dist/engine-client.d.ts.map +1 -0
  115. package/dist/engine-client.js +156 -0
  116. package/dist/engine-client.js.map +1 -0
  117. package/dist/ensure-binary.d.ts +7 -0
  118. package/dist/ensure-binary.d.ts.map +1 -0
  119. package/dist/ensure-binary.js +17 -0
  120. package/dist/ensure-binary.js.map +1 -0
  121. package/dist/functions-router-gen.d.ts +14 -0
  122. package/dist/functions-router-gen.d.ts.map +1 -0
  123. package/dist/functions-router-gen.js +199 -0
  124. package/dist/functions-router-gen.js.map +1 -0
  125. package/dist/index.d.ts +4 -5
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +2 -3
  128. package/dist/index.js.map +1 -1
  129. package/dist/kong-config.d.ts +21 -0
  130. package/dist/kong-config.d.ts.map +1 -0
  131. package/dist/kong-config.js +60 -0
  132. package/dist/kong-config.js.map +1 -0
  133. package/dist/local-gateway.d.ts +7 -0
  134. package/dist/local-gateway.d.ts.map +1 -0
  135. package/dist/local-gateway.js +9 -0
  136. package/dist/local-gateway.js.map +1 -0
  137. package/dist/local-storage.d.ts +8 -0
  138. package/dist/local-storage.d.ts.map +1 -0
  139. package/dist/local-storage.js +14 -0
  140. package/dist/local-storage.js.map +1 -0
  141. package/dist/pgbouncer-userlist.d.ts +5 -0
  142. package/dist/pgbouncer-userlist.d.ts.map +1 -0
  143. package/dist/pgbouncer-userlist.js +14 -0
  144. package/dist/pgbouncer-userlist.js.map +1 -0
  145. package/dist/postgres-ctl.d.ts +44 -0
  146. package/dist/postgres-ctl.d.ts.map +1 -0
  147. package/dist/postgres-ctl.js +137 -0
  148. package/dist/postgres-ctl.js.map +1 -0
  149. package/dist/process-manager.d.ts +41 -0
  150. package/dist/process-manager.d.ts.map +1 -0
  151. package/dist/process-manager.js +120 -0
  152. package/dist/process-manager.js.map +1 -0
  153. package/dist/project-config.d.ts +215 -0
  154. package/dist/project-config.d.ts.map +1 -0
  155. package/dist/project-config.js +145 -0
  156. package/dist/project-config.js.map +1 -0
  157. package/dist/pull-utils.d.ts +15 -0
  158. package/dist/pull-utils.d.ts.map +1 -1
  159. package/dist/pull-utils.js +12 -0
  160. package/dist/pull-utils.js.map +1 -1
  161. package/dist/release-pins.d.ts +7 -0
  162. package/dist/release-pins.d.ts.map +1 -0
  163. package/dist/release-pins.js +27 -0
  164. package/dist/release-pins.js.map +1 -0
  165. package/dist/release-public-key.d.ts +8 -0
  166. package/dist/release-public-key.d.ts.map +1 -0
  167. package/dist/release-public-key.js +13 -0
  168. package/dist/release-public-key.js.map +1 -0
  169. package/dist/runtime-routes.d.ts +25 -0
  170. package/dist/runtime-routes.d.ts.map +1 -0
  171. package/dist/runtime-routes.js +189 -0
  172. package/dist/runtime-routes.js.map +1 -0
  173. package/dist/scripts/postinstall.d.ts +5 -6
  174. package/dist/scripts/postinstall.d.ts.map +1 -1
  175. package/dist/scripts/postinstall.js +36 -20
  176. package/dist/scripts/postinstall.js.map +1 -1
  177. package/dist/self-host-compose.d.ts +14 -0
  178. package/dist/self-host-compose.d.ts.map +1 -0
  179. package/dist/self-host-compose.js +236 -0
  180. package/dist/self-host-compose.js.map +1 -0
  181. package/dist/storage-provision.d.ts +24 -0
  182. package/dist/storage-provision.d.ts.map +1 -0
  183. package/dist/storage-provision.js +44 -0
  184. package/dist/storage-provision.js.map +1 -0
  185. package/dist/systemd.d.ts +26 -0
  186. package/dist/systemd.d.ts.map +1 -0
  187. package/dist/systemd.js +102 -0
  188. package/dist/systemd.js.map +1 -0
  189. package/dist/tsx-runner.d.ts.map +1 -1
  190. package/dist/tsx-runner.js +9 -2
  191. package/dist/tsx-runner.js.map +1 -1
  192. package/dist/type-extractor.d.ts +31 -0
  193. package/dist/type-extractor.d.ts.map +1 -0
  194. package/dist/type-extractor.js +876 -0
  195. package/dist/type-extractor.js.map +1 -0
  196. package/package.json +4 -3
  197. package/releases/deno/VERSION +1 -0
  198. package/scripts/mirror-deno-release.sh +76 -0
  199. package/src/app-config.ts +128 -0
  200. package/src/augmentation-generator.ts +126 -0
  201. package/src/binary-cache.ts +802 -0
  202. package/src/cli.ts +13 -8
  203. package/src/commands/admin.ts +4 -3
  204. package/src/commands/app.ts +67 -231
  205. package/src/commands/cache.ts +117 -0
  206. package/src/commands/cloud.ts +46 -57
  207. package/src/commands/db.ts +54 -63
  208. package/src/commands/deploy.ts +110 -61
  209. package/src/commands/dev.ts +930 -405
  210. package/src/commands/diff.ts +21 -29
  211. package/src/commands/engine.ts +13 -116
  212. package/src/commands/functions.ts +97 -115
  213. package/src/commands/generate.ts +23 -10
  214. package/src/commands/init.ts +136 -414
  215. package/src/commands/migrate-from-v1.ts +131 -0
  216. package/src/commands/migrate.ts +27 -23
  217. package/src/commands/pg.ts +133 -0
  218. package/src/commands/pull.ts +6 -85
  219. package/src/commands/push.ts +128 -59
  220. package/src/commands/seed.ts +54 -12
  221. package/src/commands/self-host.ts +312 -880
  222. package/src/commands/self-update.ts +45 -0
  223. package/src/commands/status.ts +4 -3
  224. package/src/commands/types.ts +76 -0
  225. package/src/commands/update.ts +92 -0
  226. package/src/components.ts +6 -0
  227. package/src/config.ts +127 -94
  228. package/src/docker-postgres.ts +138 -0
  229. package/src/engine-client.ts +231 -0
  230. package/src/ensure-binary.ts +28 -0
  231. package/src/functions-router-gen.ts +224 -0
  232. package/src/index.ts +4 -12
  233. package/src/kong-config.ts +78 -0
  234. package/src/local-gateway.ts +9 -0
  235. package/src/local-storage.ts +14 -0
  236. package/src/pgbouncer-userlist.ts +15 -0
  237. package/src/postgres-ctl.ts +171 -0
  238. package/src/process-manager.ts +151 -0
  239. package/src/project-config.ts +353 -0
  240. package/src/pull-utils.ts +24 -0
  241. package/src/release-pins.ts +31 -0
  242. package/src/release-public-key.ts +12 -0
  243. package/src/runtime-routes.ts +216 -0
  244. package/src/scripts/postinstall.ts +36 -25
  245. package/src/self-host-compose.ts +257 -0
  246. package/src/storage-provision.ts +58 -0
  247. package/src/systemd.ts +137 -0
  248. package/src/tsx-runner.ts +11 -1
  249. package/src/type-extractor.ts +1016 -0
  250. package/tests/app-command.test.ts +54 -0
  251. package/tests/augmentation-generator.test.ts +59 -0
  252. package/tests/binary-cache-cloud-overrides.test.ts +123 -0
  253. package/tests/cached-artifact-format.test.ts +84 -0
  254. package/tests/cli-help.test.ts +40 -14
  255. package/tests/config.test.ts +140 -37
  256. package/tests/engine-distribution.test.ts +3 -3
  257. package/tests/ensure-binary.test.ts +59 -0
  258. package/tests/init.test.ts +28 -86
  259. package/tests/migrate-from-v1.test.ts +29 -0
  260. package/tests/pg-spawn-env.test.ts +18 -0
  261. package/tests/postgres-archive-tag.test.ts +9 -0
  262. package/tests/pull-utils.test.ts +36 -1
  263. package/tests/release-pins.test.ts +28 -0
  264. package/tests/runtime-contract.test.ts +236 -0
  265. package/tests/seed-discover.test.ts +31 -0
  266. package/tests/tsconfig.json +9 -0
  267. package/tests/type-extractor.test.ts +401 -0
  268. package/tsconfig.tsbuildinfo +1 -1
  269. package/vitest.config.ts +12 -0
  270. package/dist/engine/cache.d.ts +0 -37
  271. package/dist/engine/cache.d.ts.map +0 -1
  272. package/dist/engine/cache.js +0 -121
  273. package/dist/engine/cache.js.map +0 -1
  274. package/dist/engine/download.d.ts +0 -19
  275. package/dist/engine/download.d.ts.map +0 -1
  276. package/dist/engine/download.js +0 -108
  277. package/dist/engine/download.js.map +0 -1
  278. package/dist/engine/platform.d.ts +0 -24
  279. package/dist/engine/platform.d.ts.map +0 -1
  280. package/dist/engine/platform.js +0 -50
  281. package/dist/engine/platform.js.map +0 -1
  282. package/dist/engine/resolve.d.ts +0 -37
  283. package/dist/engine/resolve.d.ts.map +0 -1
  284. package/dist/engine/resolve.js +0 -133
  285. package/dist/engine/resolve.js.map +0 -1
  286. package/dist/engine/update-notify.d.ts +0 -11
  287. package/dist/engine/update-notify.d.ts.map +0 -1
  288. package/dist/engine/update-notify.js +0 -43
  289. package/dist/engine/update-notify.js.map +0 -1
  290. package/dist/engine/verify.d.ts +0 -50
  291. package/dist/engine/verify.d.ts.map +0 -1
  292. package/dist/engine/verify.js +0 -161
  293. package/dist/engine/verify.js.map +0 -1
  294. package/dist/engine-version.d.ts +0 -35
  295. package/dist/engine-version.d.ts.map +0 -1
  296. package/dist/engine-version.js +0 -35
  297. package/dist/engine-version.js.map +0 -1
  298. package/dist/engine.d.ts +0 -34
  299. package/dist/engine.d.ts.map +0 -1
  300. package/dist/engine.js +0 -76
  301. package/dist/engine.js.map +0 -1
  302. package/src/engine/cache.ts +0 -135
  303. package/src/engine/download.ts +0 -143
  304. package/src/engine/platform.ts +0 -66
  305. package/src/engine/resolve.ts +0 -197
  306. package/src/engine/update-notify.ts +0 -50
  307. package/src/engine/verify.ts +0 -206
  308. package/src/engine-version.ts +0 -39
  309. package/src/engine.ts +0 -99
@@ -0,0 +1,1016 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, isAbsolute, resolve } from "node:path"
3
+ import ts from "typescript"
4
+
5
+ type FieldAst = Record<string, unknown> & { kind: string }
6
+ type BlockDefinitionAst = {
7
+ name: string
8
+ label?: string
9
+ icon?: string
10
+ fields: Record<string, FieldAst>
11
+ }
12
+
13
+ interface ModelAst {
14
+ name: string
15
+ tableName: string
16
+ fields: Record<string, FieldAst>
17
+ access: Record<string, unknown>
18
+ indexes: unknown[]
19
+ options: Record<string, unknown>
20
+ }
21
+
22
+ /** Resolved row for `storage.buckets` — matches engine `StorageBucketAst` (camelCase JSON). */
23
+ export interface ExtractedStorageBucketAst {
24
+ id: string
25
+ public: boolean
26
+ /** `public` / `private` / `custom` — drives DB `access_mode` and S3 helpers (engine + storage server). */
27
+ accessMode?: "public" | "private" | "custom"
28
+ allowedMimeTypes?: string[]
29
+ fileSizeLimit?: number
30
+ /** Bucket-scoped `storage.objects` RLS (`read`, `create`, `delete`). */
31
+ access?: Record<string, unknown>
32
+ /** Raw S3 bucket policy JSON; overrides default public-read when `public` is true if set. */
33
+ s3BucketPolicy?: string
34
+ }
35
+
36
+ export interface ExtractedSchemaAst {
37
+ models: ModelAst[]
38
+ storageBuckets?: ExtractedStorageBucketAst[]
39
+ }
40
+
41
+ export function extractSchemaAstFromTypes(
42
+ schemaPath: string,
43
+ cwd: string = process.cwd(),
44
+ ): ExtractedSchemaAst | null {
45
+ const absPath = resolve(cwd, schemaPath)
46
+ if (!existsSync(absPath)) {
47
+ throw new Error(`Schema file not found: ${absPath}`)
48
+ }
49
+
50
+ const sourceFiles = loadSchemaSourceFiles(absPath)
51
+ const bucketAliases = new Map<string, string>()
52
+ const bucketsById = new Map<string, ExtractedStorageBucketAst>()
53
+ for (const sourceFile of sourceFiles) {
54
+ const bucketContext = collectBucketContext(sourceFile)
55
+ for (const [alias, bucketId] of bucketContext.aliases) {
56
+ bucketAliases.set(alias, bucketId)
57
+ }
58
+ for (const [bucketId, bucket] of bucketContext.bucketsById) {
59
+ const existing = bucketsById.get(bucketId)
60
+ if (existing !== undefined && !bucketsEqual(existing, bucket)) {
61
+ throw new Error(
62
+ `Conflicting Bucket<> declarations for id "${bucketId}". Use a single export per bucket id.`,
63
+ )
64
+ }
65
+ bucketsById.set(bucketId, bucket)
66
+ }
67
+ }
68
+
69
+ const blockAliases = new Map<string, BlockDefinitionAst>()
70
+ for (const sourceFile of sourceFiles) {
71
+ const next = collectBlockAliases(sourceFile, bucketAliases, bucketsById)
72
+ for (const [name, block] of next) {
73
+ blockAliases.set(name, block)
74
+ }
75
+ }
76
+
77
+ const models: ModelAst[] = []
78
+
79
+ for (const sourceFile of sourceFiles) {
80
+ for (const stmt of sourceFile.statements) {
81
+ if (!ts.isTypeAliasDeclaration(stmt)) continue
82
+ if (!hasExportModifier(stmt)) continue
83
+ if (!ts.isTypeReferenceNode(stmt.type)) continue
84
+ if (stmt.type.typeName.getText(sourceFile) !== "Model") continue
85
+ const [fieldsArg, metaArg] = stmt.type.typeArguments ?? []
86
+ if (!fieldsArg) continue
87
+ const fieldsLiteral = unwrapModelFields(fieldsArg)
88
+ if (!fieldsLiteral) continue
89
+
90
+ const fields: Record<string, FieldAst> = {}
91
+ for (const member of fieldsLiteral.members) {
92
+ if (!ts.isPropertySignature(member) || !member.type) continue
93
+ const name = getPropertyName(member.name)
94
+ if (!name) continue
95
+ fields[name] = parseFieldType(
96
+ name,
97
+ member.type,
98
+ sourceFile,
99
+ blockAliases,
100
+ bucketAliases,
101
+ bucketsById,
102
+ )
103
+ }
104
+
105
+ models.push({
106
+ name: stmt.name.text,
107
+ tableName: toSnakeCase(stmt.name.text),
108
+ fields,
109
+ access: parseModelAccess(metaArg, sourceFile),
110
+ indexes: [],
111
+ options: {},
112
+ })
113
+ }
114
+ }
115
+
116
+ if (models.length === 0) return null
117
+
118
+ const storageBuckets =
119
+ bucketsById.size > 0 ? [...bucketsById.values()].sort((a, b) => a.id.localeCompare(b.id)) : undefined
120
+
121
+ return {
122
+ models,
123
+ ...(storageBuckets !== undefined && storageBuckets.length > 0 && { storageBuckets }),
124
+ }
125
+ }
126
+
127
+ function loadSchemaSourceFiles(entryPath: string): ts.SourceFile[] {
128
+ const visited = new Set<string>()
129
+ const sourceFiles: ts.SourceFile[] = []
130
+ const queue: string[] = [entryPath]
131
+
132
+ while (queue.length > 0) {
133
+ const currentPath = queue.shift()
134
+ if (!currentPath) continue
135
+ if (visited.has(currentPath)) continue
136
+ visited.add(currentPath)
137
+
138
+ if (!existsSync(currentPath)) continue
139
+ const sourceText = readFileSync(currentPath, "utf8")
140
+ const sourceFile = ts.createSourceFile(currentPath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
141
+ sourceFiles.push(sourceFile)
142
+
143
+ const baseDir = dirname(currentPath)
144
+ for (const stmt of sourceFile.statements) {
145
+ if (!ts.isExportDeclaration(stmt)) continue
146
+ if (!stmt.moduleSpecifier || !ts.isStringLiteral(stmt.moduleSpecifier)) continue
147
+ const nextPath = resolveTypeModulePath(baseDir, stmt.moduleSpecifier.text)
148
+ if (!nextPath) continue
149
+ if (!visited.has(nextPath)) queue.push(nextPath)
150
+ }
151
+ }
152
+
153
+ return sourceFiles
154
+ }
155
+
156
+ function resolveTypeModulePath(fromDir: string, specifier: string): string | null {
157
+ const basePath = isAbsolute(specifier) ? specifier : resolve(fromDir, specifier)
158
+ const candidates = specifier.endsWith(".js")
159
+ ? [
160
+ basePath,
161
+ basePath.replace(/\.js$/i, ".ts"),
162
+ basePath.replace(/\.js$/i, ".tsx"),
163
+ basePath.replace(/\.js$/i, ".d.ts"),
164
+ ]
165
+ : [
166
+ basePath,
167
+ `${basePath}.ts`,
168
+ `${basePath}.tsx`,
169
+ `${basePath}.d.ts`,
170
+ resolve(basePath, "index.ts"),
171
+ resolve(basePath, "index.tsx"),
172
+ resolve(basePath, "index.d.ts"),
173
+ ]
174
+
175
+ for (const candidate of candidates) {
176
+ if (existsSync(candidate)) return candidate
177
+ }
178
+ return null
179
+ }
180
+
181
+ function hasExportModifier(node: ts.Node): boolean {
182
+ if (!ts.canHaveModifiers(node)) return false
183
+ return (ts.getModifiers(node)?.some((mod) => mod.kind === ts.SyntaxKind.ExportKeyword) ?? false)
184
+ }
185
+
186
+ function getPropertyName(name: ts.PropertyName): string | null {
187
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text
188
+ return null
189
+ }
190
+
191
+ function unwrapModelFields(typeNode: ts.TypeNode): ts.TypeLiteralNode | null {
192
+ if (ts.isTypeLiteralNode(typeNode)) return typeNode
193
+ if (!ts.isTypeReferenceNode(typeNode) || !ts.isIdentifier(typeNode.typeName)) return null
194
+
195
+ // Composite helpers in @supatype/types wrap the concrete field object.
196
+ if (
197
+ typeNode.typeName.text === "WithTimestamps" ||
198
+ typeNode.typeName.text === "WithSoftDelete" ||
199
+ typeNode.typeName.text === "WithPublishable"
200
+ ) {
201
+ const inner = typeNode.typeArguments?.[0]
202
+ if (!inner) return null
203
+ return unwrapModelFields(inner)
204
+ }
205
+
206
+ return null
207
+ }
208
+
209
+ function parseFieldType(
210
+ fieldName: string,
211
+ typeNode: ts.TypeNode,
212
+ sourceFile: ts.SourceFile,
213
+ blockAliases: Map<string, BlockDefinitionAst>,
214
+ bucketAliases: Map<string, string>,
215
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
216
+ ): FieldAst {
217
+ const flags = {
218
+ required: true,
219
+ unique: false,
220
+ index: false,
221
+ primaryKey: false,
222
+ serverGenerated: false,
223
+ autoIncrement: false,
224
+ relationCardinality: undefined as "one" | "many" | undefined,
225
+ relationTarget: undefined as string | undefined,
226
+ editorReadOnly: false,
227
+ /** When set from `ComputedFrom`, Studio previews from these sources until edited on create */
228
+ computedFromSources: undefined as string[] | undefined,
229
+ /** When set, second arg was a template literal with `{field}` / `{truncate(f, n)}` */
230
+ computedFromTemplate: undefined as string | undefined,
231
+ }
232
+
233
+ let current = typeNode
234
+ while (ts.isTypeReferenceNode(current) && ts.isIdentifier(current.typeName)) {
235
+ const typeName = current.typeName.text
236
+ switch (typeName) {
237
+ case "Optional":
238
+ flags.required = false
239
+ current = current.typeArguments?.[0] ?? current
240
+ continue
241
+ case "Unique":
242
+ flags.unique = true
243
+ current = current.typeArguments?.[0] ?? current
244
+ continue
245
+ case "Indexed":
246
+ flags.index = true
247
+ current = current.typeArguments?.[0] ?? current
248
+ continue
249
+ case "ServerDefault":
250
+ flags.serverGenerated = true
251
+ current = current.typeArguments?.[0] ?? current
252
+ continue
253
+ case "AutoIncrement":
254
+ flags.serverGenerated = true
255
+ flags.autoIncrement = true
256
+ current = current.typeArguments?.[0] ?? current
257
+ continue
258
+ case "PrimaryKey":
259
+ flags.primaryKey = true
260
+ flags.required = true
261
+ flags.unique = true
262
+ current = current.typeArguments?.[0] ?? current
263
+ continue
264
+ case "Default":
265
+ // Default<T, V> — unwrap to T so `Default<boolean, true>` resolves as boolean, not text.
266
+ current = current.typeArguments?.[0] ?? current
267
+ continue
268
+ case "Searchable":
269
+ current = current.typeArguments?.[0] ?? current
270
+ continue
271
+ case "EditorReadOnly":
272
+ flags.editorReadOnly = true
273
+ current = current.typeArguments?.[0] ?? current
274
+ continue
275
+ case "Computed":
276
+ flags.editorReadOnly = true
277
+ flags.serverGenerated = true
278
+ current = current.typeArguments?.[0] ?? current
279
+ continue
280
+ case "ComputedFrom": {
281
+ const valueArg = current.typeArguments?.[0]
282
+ const sourcesArg = current.typeArguments?.[1]
283
+ const parsed = parseComputedFromSecondArg(sourcesArg, sourceFile)
284
+ if (parsed) {
285
+ flags.computedFromSources = parsed.sources
286
+ flags.computedFromTemplate = parsed.template
287
+ } else {
288
+ flags.computedFromSources = ["title"]
289
+ }
290
+ current = valueArg ?? current
291
+ continue
292
+ }
293
+ case "MaxLength":
294
+ case "MinLength":
295
+ case "Between":
296
+ current = current.typeArguments?.[0] ?? current
297
+ continue
298
+ case "RelatedTo":
299
+ flags.relationCardinality = "one"
300
+ flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
301
+ // `target` must match `ModelAst.name` to satisfy validator resolution.
302
+ // FK column follows the field name (two relations to the same model need distinct columns).
303
+ return {
304
+ kind: "relation",
305
+ cardinality: "belongsTo",
306
+ target: flags.relationTarget,
307
+ foreignKey: relationForeignKeyFromField(fieldName),
308
+ ...(flags.editorReadOnly && { readOnly: true }),
309
+ }
310
+ case "HasOne":
311
+ flags.relationCardinality = "one"
312
+ flags.relationTarget = current.typeArguments?.[0]?.getText(sourceFile).replace(/\W/g, "") ?? "unknown"
313
+ return {
314
+ kind: "relation",
315
+ cardinality: "hasOne",
316
+ target: flags.relationTarget,
317
+ ...(flags.editorReadOnly && { readOnly: true }),
318
+ }
319
+ case "HasMany":
320
+ case "ManyToMany":
321
+ flags.relationCardinality = "many"
322
+ flags.relationTarget = current.typeArguments?.[0]?.getText(sourceFile).replace(/\W/g, "") ?? "unknown"
323
+ return {
324
+ kind: "relation",
325
+ cardinality: "hasMany",
326
+ target: flags.relationTarget,
327
+ ...(flags.editorReadOnly && { readOnly: true }),
328
+ }
329
+ default:
330
+ break
331
+ }
332
+ break
333
+ }
334
+
335
+ const scalar = parseScalarType(current, sourceFile, blockAliases, bucketAliases, bucketsById)
336
+ const parsed: FieldAst = {
337
+ ...scalar,
338
+ required: flags.required,
339
+ unique: flags.unique,
340
+ index: flags.index,
341
+ ...(flags.primaryKey && { primaryKey: true }),
342
+ ...(flags.editorReadOnly && { readOnly: true }),
343
+ }
344
+
345
+ if (flags.autoIncrement && parsed.kind === "integer") {
346
+ parsed.kind = "serial"
347
+ parsed.pgType = "SERIAL"
348
+ }
349
+
350
+ // RFC parity with existing examples: `id: UUID` should be the model PK unless
351
+ // explicitly overridden via wrappers such as PrimaryKey<> in source types.
352
+ if (
353
+ fieldName === "id" &&
354
+ parsed.kind === "uuid" &&
355
+ flags.primaryKey === false
356
+ ) {
357
+ parsed.primaryKey = true
358
+ parsed.unique = true
359
+ parsed.required = true
360
+ }
361
+
362
+ // Align with engine fixtures: PK UUID is created by the database unless the author supplies one.
363
+ if (parsed.primaryKey === true && parsed.kind === "uuid") {
364
+ parsed.default = { kind: "genRandomUuid" }
365
+ } else if (parsed.primaryKey === true && (parsed.kind === "serial" || parsed.kind === "bigSerial")) {
366
+ flags.serverGenerated = true
367
+ }
368
+
369
+ if (flags.serverGenerated === true) {
370
+ parsed.serverGenerated = true
371
+ }
372
+
373
+ // Convention: standard audit columns are filled by the DB on insert/update.
374
+ const auditTs =
375
+ fieldName === "created_at" ||
376
+ fieldName === "updated_at" ||
377
+ fieldName === "createdAt" ||
378
+ fieldName === "updatedAt"
379
+ if (auditTs) {
380
+ parsed.serverGenerated = true
381
+ if (
382
+ (parsed.kind === "datetime" || parsed.kind === "date") &&
383
+ parsed.default === undefined
384
+ ) {
385
+ parsed.default = { kind: "now" }
386
+ }
387
+ }
388
+
389
+ // `ServerDefault<Date>` etc. → DEFAULT NOW() for column types Postgres handles with NOW().
390
+ if (
391
+ flags.serverGenerated &&
392
+ (parsed.kind === "datetime" || parsed.kind === "date") &&
393
+ parsed.default === undefined
394
+ ) {
395
+ parsed.default = { kind: "now" }
396
+ }
397
+
398
+ const hasCfTemplate = flags.computedFromTemplate !== undefined
399
+ const hasCfSources = Boolean(flags.computedFromSources && flags.computedFromSources.length > 0)
400
+ if (parsed.kind === "text" && (hasCfTemplate || hasCfSources)) {
401
+ return {
402
+ ...parsed,
403
+ ...(hasCfSources && { sources: flags.computedFromSources! }),
404
+ ...(hasCfTemplate && { template: flags.computedFromTemplate }),
405
+ }
406
+ }
407
+
408
+ return parsed
409
+ }
410
+
411
+ function parseScalarType(
412
+ typeNode: ts.TypeNode,
413
+ sourceFile: ts.SourceFile,
414
+ blockAliases: Map<string, BlockDefinitionAst>,
415
+ bucketAliases: Map<string, string>,
416
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
417
+ ): FieldAst {
418
+ if (ts.isArrayTypeNode(typeNode)) {
419
+ const element = parseScalarType(typeNode.elementType, sourceFile, blockAliases, bucketAliases, bucketsById)
420
+ const elementKind = typeof element.kind === "string" ? element.kind : "text"
421
+ // Keep arrays as native SQL arrays (old `arrayOf(...)` parity), not JSONB.
422
+ return {
423
+ kind: "array",
424
+ pgType: "ARRAY",
425
+ elementType: elementKind,
426
+ }
427
+ }
428
+
429
+ if (ts.isUnionTypeNode(typeNode)) {
430
+ const literals = typeNode.types.filter(ts.isLiteralTypeNode)
431
+ if (literals.length === typeNode.types.length && literals.every((lit) => ts.isStringLiteral(lit.literal))) {
432
+ return {
433
+ kind: "enum",
434
+ pgType: "TEXT",
435
+ values: literals.map((lit) => (lit.literal as ts.StringLiteral).text),
436
+ }
437
+ }
438
+ const nonNull = typeNode.types.find((t) => t.kind !== ts.SyntaxKind.NullKeyword)
439
+ if (nonNull) return parseScalarType(nonNull, sourceFile, blockAliases, bucketAliases, bucketsById)
440
+ }
441
+
442
+ if (ts.isTypeReferenceNode(typeNode)) {
443
+ const ref = typeNode.typeName.getText(sourceFile)
444
+ switch (ref) {
445
+ case "UUID":
446
+ case "SupatypeAuthUserId":
447
+ return { kind: "uuid", pgType: "UUID" }
448
+ case "RichText":
449
+ return { kind: "richText", pgType: "JSONB" }
450
+ case "Slug": {
451
+ const fromArg = typeNode.typeArguments?.[0]
452
+ const fromLiteral = fromArg ? literalStringType(fromArg) : null
453
+ const from = fromLiteral ?? "title"
454
+ return { kind: "slug", pgType: "TEXT", from }
455
+ }
456
+ case "Email":
457
+ return { kind: "email", pgType: "TEXT" }
458
+ case "URL":
459
+ return { kind: "url", pgType: "TEXT" }
460
+ case "Markdown":
461
+ return { kind: "text", pgType: "TEXT" }
462
+ case "Color":
463
+ return { kind: "color", pgType: "TEXT" }
464
+ case "PhoneNumber":
465
+ return { kind: "text", pgType: "TEXT" }
466
+ case "IPAddress":
467
+ return { kind: "ip", pgType: "TEXT" }
468
+ case "CIDR":
469
+ return { kind: "cidr", pgType: "TEXT" }
470
+ case "MacAddress":
471
+ return { kind: "macaddr", pgType: "TEXT" }
472
+ case "XML":
473
+ return { kind: "xml", pgType: "TEXT" }
474
+ case "TSQuery":
475
+ return { kind: "tsQuery", pgType: "TEXT" }
476
+ case "TSVector":
477
+ return { kind: "tsVector", pgType: "TEXT" }
478
+ case "Money":
479
+ return { kind: "money", pgType: "TEXT" }
480
+ case "Decimal":
481
+ return { kind: "decimal", pgType: "TEXT" }
482
+ case "DateOnly":
483
+ return { kind: "date", pgType: "DATE" }
484
+ case "Date":
485
+ case "DateTime":
486
+ case "Timestamp":
487
+ return { kind: "datetime", pgType: "TIMESTAMP WITH TIME ZONE" }
488
+ case "Int":
489
+ return { kind: "integer", pgType: "INTEGER" }
490
+ case "SmallInt":
491
+ return { kind: "smallInt", pgType: "SMALLINT" }
492
+ case "BigInt":
493
+ return { kind: "bigInt", pgType: "BIGINT" }
494
+ case "Float":
495
+ return { kind: "float", pgType: "DOUBLE PRECISION" }
496
+ case "Bytea":
497
+ return { kind: "bytes", pgType: "BYTEA" }
498
+ case "JSON":
499
+ return { kind: "json", pgType: "JSONB" }
500
+ case "GeoPoint":
501
+ return { kind: "geo", pgType: "GEOGRAPHY", geoType: "point", srid: 4326 }
502
+ case "Geo":
503
+ return { kind: "geo", pgType: "GEOGRAPHY", geoType: "point", srid: 4326 }
504
+ case "Asset":
505
+ case "FileAsset": {
506
+ const bucket = resolveBucketName(typeNode.typeArguments?.[0], sourceFile, bucketAliases, "assets")
507
+ return attachStorageFieldMeta({ kind: "file", pgType: "TEXT", bucket }, bucket, bucketsById)
508
+ }
509
+ case "ImageAsset": {
510
+ const bucket = resolveBucketName(typeNode.typeArguments?.[0], sourceFile, bucketAliases, "images")
511
+ return attachStorageFieldMeta({ kind: "image", pgType: "TEXT", bucket }, bucket, bucketsById)
512
+ }
513
+ case "Blocks":
514
+ return {
515
+ kind: "blocks",
516
+ pgType: "JSONB",
517
+ blocks: parseBlocksTypeDefinitions(
518
+ typeNode.typeArguments?.[0],
519
+ sourceFile,
520
+ blockAliases,
521
+ bucketAliases,
522
+ bucketsById,
523
+ ),
524
+ }
525
+ case "Vector": {
526
+ const dimensions = typeNode.typeArguments?.[0]?.getText(sourceFile)
527
+ return { kind: "vector", pgType: "VECTOR", dimensions: Number(dimensions ?? "1536") }
528
+ }
529
+ default:
530
+ return { kind: "text", pgType: "TEXT" }
531
+ }
532
+ }
533
+
534
+ switch (typeNode.kind) {
535
+ case ts.SyntaxKind.StringKeyword:
536
+ return { kind: "text", pgType: "TEXT" }
537
+ case ts.SyntaxKind.NumberKeyword:
538
+ return { kind: "float", pgType: "DOUBLE PRECISION" }
539
+ case ts.SyntaxKind.BooleanKeyword:
540
+ return { kind: "boolean", pgType: "BOOLEAN" }
541
+ default:
542
+ return { kind: "json", pgType: "JSONB" }
543
+ }
544
+ }
545
+
546
+ function collectBlockAliases(
547
+ sourceFile: ts.SourceFile,
548
+ bucketAliases: Map<string, string>,
549
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
550
+ ): Map<string, BlockDefinitionAst> {
551
+ const blocks = new Map<string, BlockDefinitionAst>()
552
+ for (const stmt of sourceFile.statements) {
553
+ if (!ts.isTypeAliasDeclaration(stmt)) continue
554
+ if (!ts.isTypeReferenceNode(stmt.type)) continue
555
+ if (!ts.isIdentifier(stmt.type.typeName) || stmt.type.typeName.text !== "Block") continue
556
+ const block = parseInlineBlockDefinition(stmt.type, sourceFile, new Map(), bucketAliases, bucketsById)
557
+ if (!block) continue
558
+ blocks.set(stmt.name.text, block)
559
+ }
560
+ return blocks
561
+ }
562
+
563
+ function collectBucketContext(sourceFile: ts.SourceFile): {
564
+ aliases: Map<string, string>
565
+ bucketsById: Map<string, ExtractedStorageBucketAst>
566
+ } {
567
+ const aliases = new Map<string, string>()
568
+ const bucketsById = new Map<string, ExtractedStorageBucketAst>()
569
+
570
+ for (const stmt of sourceFile.statements) {
571
+ if (!ts.isTypeAliasDeclaration(stmt)) continue
572
+ if (!ts.isTypeReferenceNode(stmt.type)) continue
573
+ if (!ts.isIdentifier(stmt.type.typeName) || stmt.type.typeName.text !== "Bucket") continue
574
+ const [nameArg, configArg] = stmt.type.typeArguments ?? []
575
+ if (!nameArg || !ts.isLiteralTypeNode(nameArg) || !ts.isStringLiteral(nameArg.literal)) continue
576
+ const id = nameArg.literal.text
577
+ aliases.set(stmt.name.text, id)
578
+
579
+ const parsed =
580
+ configArg && ts.isTypeLiteralNode(configArg)
581
+ ? parseBucketTypeLiteral(configArg, sourceFile)
582
+ : {}
583
+
584
+ const next = buildExtractedBucketAst(id, parsed)
585
+ const existing = bucketsById.get(id)
586
+ if (existing !== undefined && !bucketsEqual(existing, next)) {
587
+ throw new Error(
588
+ `Conflicting Bucket<> declarations for id "${id}". Use a single export per bucket id.`,
589
+ )
590
+ }
591
+ bucketsById.set(id, next)
592
+ }
593
+
594
+ return { aliases, bucketsById }
595
+ }
596
+
597
+ function buildExtractedBucketAst(
598
+ id: string,
599
+ parsed: Partial<ParsedBucketLiteral>,
600
+ ): ExtractedStorageBucketAst {
601
+ const mode = parsed.accessMode ?? "private"
602
+ const pub = mode === "public"
603
+
604
+ const row: ExtractedStorageBucketAst = {
605
+ id,
606
+ public: pub,
607
+ accessMode: mode,
608
+ ...(parsed.allowedMimeTypes !== undefined && parsed.allowedMimeTypes.length > 0
609
+ ? { allowedMimeTypes: parsed.allowedMimeTypes }
610
+ : {}),
611
+ ...(parsed.fileSizeLimit !== undefined ? { fileSizeLimit: parsed.fileSizeLimit } : {}),
612
+ ...(parsed.access !== undefined &&
613
+ Object.keys(parsed.access).length > 0 && { access: parsed.access }),
614
+ ...(parsed.s3BucketPolicy !== undefined ? { s3BucketPolicy: parsed.s3BucketPolicy } : {}),
615
+ }
616
+ return row
617
+ }
618
+
619
+ interface ParsedBucketLiteral {
620
+ accessMode?: "public" | "private" | "custom"
621
+ allowedMimeTypes?: string[]
622
+ fileSizeLimit?: number
623
+ access?: Record<string, unknown>
624
+ s3BucketPolicy?: string
625
+ }
626
+
627
+ function parseBucketTypeLiteral(
628
+ lit: ts.TypeLiteralNode,
629
+ sourceFile: ts.SourceFile,
630
+ ): Partial<ParsedBucketLiteral> {
631
+ const out: Partial<ParsedBucketLiteral> = {}
632
+ for (const member of lit.members) {
633
+ if (!ts.isPropertySignature(member) || !member.type) continue
634
+ const key = getPropertyName(member.name)
635
+ if (!key) continue
636
+
637
+ if (key === "accessMode") {
638
+ const mode = parseAccessModeLiteral(member.type, sourceFile)
639
+ if (mode !== undefined) out.accessMode = mode
640
+ continue
641
+ }
642
+ if (key === "maxSize") {
643
+ const s = parseSizeStringLiteral(member.type, sourceFile)
644
+ if (s !== undefined) {
645
+ const bytes = parseDataSizeBytes(s)
646
+ out.fileSizeLimit = bytes
647
+ }
648
+ continue
649
+ }
650
+ if (key === "accept") {
651
+ const types = parseMimeAcceptList(member.type, sourceFile)
652
+ if (types !== undefined) out.allowedMimeTypes = types
653
+ continue
654
+ }
655
+ if (key === "access") {
656
+ const acc = parsePartialBucketAccess(member.type, sourceFile)
657
+ if (acc !== undefined && Object.keys(acc).length > 0) out.access = acc
658
+ continue
659
+ }
660
+ if (key === "s3BucketPolicy") {
661
+ const pol = parseJsonStringLiteral(member.type, sourceFile)
662
+ if (pol !== undefined) out.s3BucketPolicy = pol
663
+ continue
664
+ }
665
+ }
666
+ return out
667
+ }
668
+
669
+ function parseAccessModeLiteral(
670
+ typeNode: ts.TypeNode,
671
+ sourceFile: ts.SourceFile,
672
+ ): "public" | "private" | "custom" | undefined {
673
+ const text = stripQuotes(typeNode.getText(sourceFile))
674
+ if (text === "public" || text === "private" || text === "custom") return text
675
+ return undefined
676
+ }
677
+
678
+ function parseSizeStringLiteral(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): string | undefined {
679
+ if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) {
680
+ return typeNode.literal.text
681
+ }
682
+ return stripQuotes(typeNode.getText(sourceFile)) || undefined
683
+ }
684
+
685
+ function parseJsonStringLiteral(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): string | undefined {
686
+ return parseSizeStringLiteral(typeNode, sourceFile)
687
+ }
688
+
689
+ function parseMimeAcceptList(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): string[] | undefined {
690
+ if (ts.isTypeOperatorNode(typeNode) && typeNode.operator === ts.SyntaxKind.ReadonlyKeyword) {
691
+ return parseMimeAcceptList(typeNode.type, sourceFile)
692
+ }
693
+ if (ts.isTupleTypeNode(typeNode)) {
694
+ const values: string[] = []
695
+ for (const el of typeNode.elements) {
696
+ const node: ts.TypeNode = ts.isNamedTupleMember(el) ? el.type : el
697
+ const s = literalStringType(node)
698
+ if (!s) return undefined
699
+ values.push(s)
700
+ }
701
+ return values.length > 0 ? values : undefined
702
+ }
703
+ if (ts.isUnionTypeNode(typeNode)) {
704
+ const values: string[] = []
705
+ for (const u of typeNode.types) {
706
+ const s = literalStringType(u)
707
+ if (!s) return undefined
708
+ values.push(s)
709
+ }
710
+ return values.length > 0 ? values : undefined
711
+ }
712
+ return undefined
713
+ }
714
+
715
+ function parsePartialBucketAccess(
716
+ typeNode: ts.TypeNode,
717
+ sourceFile: ts.SourceFile,
718
+ ): Record<string, unknown> | undefined {
719
+ if (!ts.isTypeLiteralNode(typeNode)) return undefined
720
+ const access: Record<string, unknown> = {}
721
+ for (const member of typeNode.members) {
722
+ if (!ts.isPropertySignature(member) || !member.type) continue
723
+ const key = getPropertyName(member.name)
724
+ if (key !== "read" && key !== "create" && key !== "delete") continue
725
+ access[key] = parseAccessRule(member.type, sourceFile)
726
+ }
727
+ return access
728
+ }
729
+
730
+ /** Parse human-readable size from schema types, e.g. `50MB`. */
731
+ function parseDataSizeBytes(lit: string): number {
732
+ const m = lit.trim().match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)$/i)
733
+ if (!m?.[1] || !m[2]) throw new Error(`Invalid maxSize literal: "${lit}". Use forms like "50MB", "100KB".`)
734
+ const n = Number(m[1])
735
+ if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid maxSize number in: "${lit}"`)
736
+ const pow: Record<string, number> = {
737
+ B: 0,
738
+ KB: 10,
739
+ MB: 20,
740
+ GB: 30,
741
+ }
742
+ const unit = m[2].toUpperCase() as keyof typeof pow
743
+ const exp = pow[unit]
744
+ if (exp === undefined) throw new Error(`Unsupported maxSize unit in: "${lit}"`)
745
+ return Math.round(n * Math.pow(2, exp))
746
+ }
747
+
748
+ function stripQuotes(s: string): string {
749
+ return s.replace(/^['"]|['"]$/g, "")
750
+ }
751
+
752
+ function bucketsEqual(a: ExtractedStorageBucketAst, b: ExtractedStorageBucketAst): boolean {
753
+ return (
754
+ a.public === b.public &&
755
+ (a.accessMode ?? "private") === (b.accessMode ?? "private") &&
756
+ JSON.stringify(a.access ?? null) === JSON.stringify(b.access ?? null) &&
757
+ JSON.stringify(a.allowedMimeTypes ?? null) === JSON.stringify(b.allowedMimeTypes ?? null) &&
758
+ (a.fileSizeLimit ?? null) === (b.fileSizeLimit ?? null) &&
759
+ (a.s3BucketPolicy ?? null) === (b.s3BucketPolicy ?? null)
760
+ )
761
+ }
762
+
763
+ function attachStorageFieldMeta(
764
+ field: FieldAst,
765
+ bucketId: string,
766
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
767
+ ): FieldAst {
768
+ const cfg = bucketsById.get(bucketId)
769
+ if (cfg?.accessMode !== undefined) {
770
+ return {
771
+ ...field,
772
+ ...(cfg.accessMode !== undefined && { accessMode: cfg.accessMode }),
773
+ }
774
+ }
775
+ return field
776
+ }
777
+
778
+ function parseBlocksTypeDefinitions(
779
+ blocksArg: ts.TypeNode | undefined,
780
+ sourceFile: ts.SourceFile,
781
+ blockAliases: Map<string, BlockDefinitionAst>,
782
+ bucketAliases: Map<string, string>,
783
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
784
+ ): BlockDefinitionAst[] {
785
+ if (!blocksArg) return []
786
+ const parts = ts.isUnionTypeNode(blocksArg) ? blocksArg.types : [blocksArg]
787
+ const out: BlockDefinitionAst[] = []
788
+ for (const part of parts) {
789
+ if (ts.isTypeReferenceNode(part) && ts.isIdentifier(part.typeName)) {
790
+ if (part.typeName.text === "Block") {
791
+ const inline = parseInlineBlockDefinition(part, sourceFile, blockAliases, bucketAliases, bucketsById)
792
+ if (inline) out.push(inline)
793
+ continue
794
+ }
795
+ const aliased = blockAliases.get(part.typeName.text)
796
+ if (aliased) out.push(aliased)
797
+ }
798
+ }
799
+ return out
800
+ }
801
+
802
+ function parseInlineBlockDefinition(
803
+ ref: ts.TypeReferenceNode,
804
+ sourceFile: ts.SourceFile,
805
+ blockAliases: Map<string, BlockDefinitionAst>,
806
+ bucketAliases: Map<string, string>,
807
+ bucketsById: Map<string, ExtractedStorageBucketAst>,
808
+ ): BlockDefinitionAst | null {
809
+ const [nameArg, fieldsArg, metaArg] = ref.typeArguments ?? []
810
+ const name = literalStringType(nameArg)
811
+ if (!name || !fieldsArg || !ts.isTypeLiteralNode(fieldsArg)) return null
812
+
813
+ const fields: Record<string, FieldAst> = {}
814
+ for (const member of fieldsArg.members) {
815
+ if (!ts.isPropertySignature(member) || !member.type) continue
816
+ const fieldName = getPropertyName(member.name)
817
+ if (!fieldName) continue
818
+ fields[fieldName] = parseFieldType(
819
+ fieldName,
820
+ member.type,
821
+ sourceFile,
822
+ blockAliases,
823
+ bucketAliases,
824
+ bucketsById,
825
+ )
826
+ }
827
+
828
+ let label: string | undefined
829
+ let icon: string | undefined
830
+ if (metaArg && ts.isTypeLiteralNode(metaArg)) {
831
+ for (const m of metaArg.members) {
832
+ if (!ts.isPropertySignature(m) || !m.type) continue
833
+ const key = getPropertyName(m.name)
834
+ if (!key) continue
835
+ const value = literalStringType(m.type)
836
+ if (!value) continue
837
+ if (key === "label") label = value
838
+ if (key === "icon") icon = value
839
+ }
840
+ }
841
+
842
+ return {
843
+ name,
844
+ ...(label !== undefined && { label }),
845
+ ...(icon !== undefined && { icon }),
846
+ fields,
847
+ }
848
+ }
849
+
850
+ function literalStringType(typeNode: ts.TypeNode | undefined): string | null {
851
+ if (!typeNode) return null
852
+ if (ts.isLiteralTypeNode(typeNode) && ts.isStringLiteral(typeNode.literal)) return typeNode.literal.text
853
+ return null
854
+ }
855
+
856
+ /** Field names referenced in `{name}` and `{truncate(name, n)}` (case-sensitive, same as model fields). */
857
+ function fieldNamesInComputedTemplate(template: string): string[] {
858
+ const fields = new Set<string>()
859
+ const reTrunc = /\{truncate\s*\(\s*([a-zA-Z_]\w*)\s*,\s*(\d+)\s*\)\}/gi
860
+ let m: RegExpExecArray | null
861
+ while ((m = reTrunc.exec(template)) !== null) {
862
+ const ref = m[1]
863
+ if (ref) fields.add(ref)
864
+ }
865
+ const reSimple = /\{([a-zA-Z_]\w*)\}/g
866
+ while ((m = reSimple.exec(template)) !== null) {
867
+ const ref = m[1]
868
+ if (ref) fields.add(ref)
869
+ }
870
+ return [...fields]
871
+ }
872
+
873
+ function looksLikeComputedTemplateLiteral(lit: string): boolean {
874
+ return /\{truncate\s*\(/i.test(lit) || /\{[a-zA-Z_]\w*\}/g.test(lit)
875
+ }
876
+
877
+ /** Resolves second type arg of `ComputedFrom<Value, Sources>` — tuple concat, single field, or template literal. */
878
+ function parseComputedFromSecondArg(
879
+ sourcesArg: ts.TypeNode | undefined,
880
+ sourceFile: ts.SourceFile,
881
+ ): { sources: string[]; template?: string } | null {
882
+ if (!sourcesArg) return null
883
+ const single = literalStringType(sourcesArg)
884
+ if (single) {
885
+ if (looksLikeComputedTemplateLiteral(single)) {
886
+ return { sources: fieldNamesInComputedTemplate(single), template: single }
887
+ }
888
+ return { sources: [single] }
889
+ }
890
+
891
+ const elemsFromTupleType = (tuple: ts.TupleTypeNode): ts.TypeNode[] | null => {
892
+ const nodes: ts.TypeNode[] = []
893
+ for (const el of tuple.elements) {
894
+ if (ts.isNamedTupleMember(el)) {
895
+ if (!el.type) return null
896
+ nodes.push(el.type)
897
+ continue
898
+ }
899
+ nodes.push(el as ts.TypeNode)
900
+ }
901
+ return nodes
902
+ }
903
+
904
+ const tupleElems = (): ts.TypeNode[] | null => {
905
+ if (ts.isTupleTypeNode(sourcesArg)) return elemsFromTupleType(sourcesArg)
906
+ if (ts.isTypeOperatorNode(sourcesArg) && sourcesArg.operator === ts.SyntaxKind.ReadonlyKeyword) {
907
+ const inner = sourcesArg.type
908
+ if (inner && ts.isTupleTypeNode(inner)) return elemsFromTupleType(inner)
909
+ }
910
+ return null
911
+ }
912
+
913
+ const elems = tupleElems()
914
+ if (!elems || elems.length === 0) return null
915
+ const keys: string[] = []
916
+ for (const node of elems) {
917
+ const k = literalStringType(node)
918
+ if (!k) return null
919
+ keys.push(k)
920
+ }
921
+ return { sources: keys }
922
+ }
923
+
924
+ function resolveBucketName(
925
+ typeArg: ts.TypeNode | undefined,
926
+ sourceFile: ts.SourceFile,
927
+ bucketAliases: Map<string, string>,
928
+ fallback: string,
929
+ ): string {
930
+ if (!typeArg) return fallback
931
+ if (ts.isTypeReferenceNode(typeArg) && ts.isIdentifier(typeArg.typeName)) {
932
+ return bucketAliases.get(typeArg.typeName.text) ?? typeArg.typeName.text
933
+ }
934
+ if (ts.isLiteralTypeNode(typeArg) && ts.isStringLiteral(typeArg.literal)) {
935
+ return typeArg.literal.text
936
+ }
937
+ return typeArg.getText(sourceFile).replace(/^['"]|['"]$/g, "") || fallback
938
+ }
939
+
940
+ function parseModelAccess(metaArg: ts.TypeNode | undefined, sourceFile: ts.SourceFile): Record<string, unknown> {
941
+ if (!metaArg || !ts.isTypeLiteralNode(metaArg)) return {}
942
+ const accessProp = metaArg.members.find(
943
+ (member) => ts.isPropertySignature(member) && getPropertyName(member.name) === "access",
944
+ )
945
+ if (!accessProp || !ts.isPropertySignature(accessProp) || !accessProp.type || !ts.isTypeLiteralNode(accessProp.type)) {
946
+ return {}
947
+ }
948
+
949
+ const access: Record<string, unknown> = {}
950
+ for (const member of accessProp.type.members) {
951
+ if (!ts.isPropertySignature(member) || !member.type) continue
952
+ const key = getPropertyName(member.name)
953
+ if (!key) continue
954
+ access[key] = parseAccessRule(member.type, sourceFile)
955
+ }
956
+ return access
957
+ }
958
+
959
+ function parseAccessRule(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): Record<string, unknown> {
960
+ if (!ts.isTypeReferenceNode(typeNode)) return { type: "private" }
961
+ const ref = typeNode.typeName.getText(sourceFile)
962
+ switch (ref) {
963
+ case "Public":
964
+ case "BucketPublic":
965
+ return { type: "public" }
966
+ case "LoggedIn":
967
+ case "BucketLoggedIn":
968
+ return { type: "authenticated" }
969
+ case "Private":
970
+ case "BucketPrivate":
971
+ return { type: "private" }
972
+ case "BucketOwner":
973
+ return { type: "owner", field: "owner_id" }
974
+ case "Owner": {
975
+ const args = typeNode.typeArguments ?? []
976
+ const keyArg = args.length >= 2 ? args[1] : args[0]
977
+ // Must match engine `AccessRule::Owner { field }` (see supatype-schema-engine parser/ast.rs).
978
+ return { type: "owner", field: keyArg?.getText(sourceFile).replace(/['"]/g, "") ?? "user_id" }
979
+ }
980
+ case "OwnerFrom": {
981
+ const relationArg = typeNode.typeArguments?.[0]
982
+ const relationField = relationArg?.getText(sourceFile).replace(/['"]/g, "") ?? "owner"
983
+ return { type: "owner", field: relationForeignKeyFromField(relationField) }
984
+ }
985
+ case "Role": {
986
+ const roleArg = typeNode.typeArguments?.[0]
987
+ return { type: "role", roles: [roleArg?.getText(sourceFile).replace(/['"]/g, "") ?? "admin"] }
988
+ }
989
+ case "BucketRole": {
990
+ const roleArg = typeNode.typeArguments?.[0]
991
+ return { type: "role", roles: [roleArg?.getText(sourceFile).replace(/['"]/g, "") ?? "admin"] }
992
+ }
993
+ default:
994
+ return { type: "private" }
995
+ }
996
+ }
997
+
998
+ function relationTargetFromTypeArg(typeArg: ts.TypeNode | undefined, sourceFile: ts.SourceFile): string {
999
+ if (!typeArg) return "unknown"
1000
+ const raw = typeArg.getText(sourceFile).replace(/\s/g, "")
1001
+ if (raw === "SupatypeAuthUser") return "supatype:user"
1002
+ return raw.replace(/\W/g, "")
1003
+ }
1004
+
1005
+ function toSnakeCase(s: string): string {
1006
+ return s.replace(/([A-Z])/g, "_$1").replace(/^_/, "").toLowerCase()
1007
+ }
1008
+
1009
+ function relationForeignKeyFromField(fieldName: string): string {
1010
+ const snake = fieldName
1011
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
1012
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
1013
+ .toLowerCase()
1014
+ const base = snake.replace(/_id$/i, "")
1015
+ return `${base}_id`
1016
+ }