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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +67 -62
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/app/proxy-dev-app.d.ts +13 -0
  5. package/dist/app/proxy-dev-app.d.ts.map +1 -0
  6. package/dist/app/proxy-dev-app.js +53 -0
  7. package/dist/app/proxy-dev-app.js.map +1 -0
  8. package/dist/binary-cache.d.ts +5 -0
  9. package/dist/binary-cache.d.ts.map +1 -1
  10. package/dist/binary-cache.js +13 -0
  11. package/dist/binary-cache.js.map +1 -1
  12. package/dist/commands/cloud.d.ts +11 -3
  13. package/dist/commands/cloud.d.ts.map +1 -1
  14. package/dist/commands/cloud.js +33 -25
  15. package/dist/commands/cloud.js.map +1 -1
  16. package/dist/commands/deploy.d.ts.map +1 -1
  17. package/dist/commands/deploy.js +3 -17
  18. package/dist/commands/deploy.js.map +1 -1
  19. package/dist/commands/dev.d.ts +3 -3
  20. package/dist/commands/dev.d.ts.map +1 -1
  21. package/dist/commands/dev.js +66 -59
  22. package/dist/commands/dev.js.map +1 -1
  23. package/dist/commands/diff.d.ts.map +1 -1
  24. package/dist/commands/diff.js +11 -1
  25. package/dist/commands/diff.js.map +1 -1
  26. package/dist/commands/init.js +16 -3
  27. package/dist/commands/init.js.map +1 -1
  28. package/dist/commands/push.d.ts.map +1 -1
  29. package/dist/commands/push.js +42 -12
  30. package/dist/commands/push.js.map +1 -1
  31. package/dist/commands/update.d.ts.map +1 -1
  32. package/dist/commands/update.js +16 -0
  33. package/dist/commands/update.js.map +1 -1
  34. package/dist/dev-compose.d.ts +17 -0
  35. package/dist/dev-compose.d.ts.map +1 -0
  36. package/dist/dev-compose.js +374 -0
  37. package/dist/dev-compose.js.map +1 -0
  38. package/dist/diff-output.d.ts +4 -0
  39. package/dist/diff-output.d.ts.map +1 -0
  40. package/dist/diff-output.js +12 -0
  41. package/dist/diff-output.js.map +1 -0
  42. package/dist/docker-postgres.d.ts +21 -3
  43. package/dist/docker-postgres.d.ts.map +1 -1
  44. package/dist/docker-postgres.js +130 -18
  45. package/dist/docker-postgres.js.map +1 -1
  46. package/dist/engine-client.d.ts +5 -3
  47. package/dist/engine-client.d.ts.map +1 -1
  48. package/dist/engine-client.js +2 -1
  49. package/dist/engine-client.js.map +1 -1
  50. package/dist/kong-config.d.ts +4 -0
  51. package/dist/kong-config.d.ts.map +1 -1
  52. package/dist/kong-config.js +12 -1
  53. package/dist/kong-config.js.map +1 -1
  54. package/dist/process-manager.d.ts +2 -0
  55. package/dist/process-manager.d.ts.map +1 -1
  56. package/dist/process-manager.js +16 -1
  57. package/dist/process-manager.js.map +1 -1
  58. package/dist/project-config.d.ts +21 -1
  59. package/dist/project-config.d.ts.map +1 -1
  60. package/dist/project-config.js +15 -0
  61. package/dist/project-config.js.map +1 -1
  62. package/dist/runtime-routes.d.ts +9 -0
  63. package/dist/runtime-routes.d.ts.map +1 -1
  64. package/dist/runtime-routes.js +75 -12
  65. package/dist/runtime-routes.js.map +1 -1
  66. package/dist/schema-ast-v2.d.ts +127 -0
  67. package/dist/schema-ast-v2.d.ts.map +1 -0
  68. package/dist/schema-ast-v2.js +226 -0
  69. package/dist/schema-ast-v2.js.map +1 -0
  70. package/dist/self-host-compose.d.ts +12 -4
  71. package/dist/self-host-compose.d.ts.map +1 -1
  72. package/dist/self-host-compose.js +146 -35
  73. package/dist/self-host-compose.js.map +1 -1
  74. package/dist/studio-admin-roles.d.ts +7 -0
  75. package/dist/studio-admin-roles.d.ts.map +1 -0
  76. package/dist/studio-admin-roles.js +14 -0
  77. package/dist/studio-admin-roles.js.map +1 -0
  78. package/dist/studio-dev-server.d.ts +22 -0
  79. package/dist/studio-dev-server.d.ts.map +1 -0
  80. package/dist/studio-dev-server.js +28 -0
  81. package/dist/studio-dev-server.js.map +1 -0
  82. package/dist/type-extractor.d.ts +3 -30
  83. package/dist/type-extractor.d.ts.map +1 -1
  84. package/dist/type-extractor.js +485 -148
  85. package/dist/type-extractor.js.map +1 -1
  86. package/dist/type-resolver.d.ts +33 -0
  87. package/dist/type-resolver.d.ts.map +1 -0
  88. package/dist/type-resolver.js +338 -0
  89. package/dist/type-resolver.js.map +1 -0
  90. package/package.json +1 -1
  91. package/src/TYPE-RESOLUTION.md +294 -0
  92. package/src/app/proxy-dev-app.ts +67 -0
  93. package/src/binary-cache.ts +20 -0
  94. package/src/commands/cloud.ts +40 -30
  95. package/src/commands/deploy.ts +3 -18
  96. package/src/commands/dev.ts +72 -69
  97. package/src/commands/diff.ts +11 -1
  98. package/src/commands/init.ts +16 -3
  99. package/src/commands/push.ts +49 -13
  100. package/src/commands/update.ts +17 -0
  101. package/src/dev-compose.ts +455 -0
  102. package/src/diff-output.ts +12 -0
  103. package/src/docker-postgres.ts +184 -27
  104. package/src/engine-client.ts +9 -4
  105. package/src/kong-config.ts +16 -1
  106. package/src/process-manager.ts +18 -1
  107. package/src/project-config.ts +34 -1
  108. package/src/runtime-routes.ts +87 -12
  109. package/src/schema-ast-v2.ts +324 -0
  110. package/src/self-host-compose.ts +168 -36
  111. package/src/studio-admin-roles.ts +16 -0
  112. package/src/studio-dev-server.ts +53 -0
  113. package/src/type-extractor.ts +649 -186
  114. package/src/type-resolver.ts +457 -0
  115. package/tests/config.test.ts +34 -3
  116. package/tests/docker-postgres.test.ts +39 -0
  117. package/tests/normalize-admin-config.test.ts +48 -0
  118. package/tests/proxy-dev-app.test.ts +33 -0
  119. package/tests/runtime-contract.test.ts +119 -4
  120. package/tests/studio-admin-roles.test.ts +27 -0
  121. package/tests/type-extractor.test.ts +607 -23
  122. package/tests/type-resolver.test.ts +59 -0
  123. package/tsconfig.tsbuildinfo +1 -1
@@ -1,53 +1,46 @@
1
1
  import { existsSync, readFileSync } from "node:fs"
2
2
  import { dirname, isAbsolute, resolve } from "node:path"
3
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[]
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
39
31
  }
40
32
 
41
33
  export function extractSchemaAstFromTypes(
42
34
  schemaPath: string,
43
35
  cwd: string = process.cwd(),
44
- ): ExtractedSchemaAst | null {
36
+ ): ExtractedSchemaAstV2 | null {
45
37
  const absPath = resolve(cwd, schemaPath)
46
38
  if (!existsSync(absPath)) {
47
39
  throw new Error(`Schema file not found: ${absPath}`)
48
40
  }
49
41
 
50
42
  const sourceFiles = loadSchemaSourceFiles(absPath)
43
+ const resolveCtx = createResolveContext(sourceFiles)
51
44
  const bucketAliases = new Map<string, string>()
52
45
  const bucketsById = new Map<string, ExtractedStorageBucketAst>()
53
46
  for (const sourceFile of sourceFiles) {
@@ -68,26 +61,32 @@ export function extractSchemaAstFromTypes(
68
61
 
69
62
  const blockAliases = new Map<string, BlockDefinitionAst>()
70
63
  for (const sourceFile of sourceFiles) {
71
- const next = collectBlockAliases(sourceFile, bucketAliases, bucketsById)
64
+ const next = collectBlockAliases(sourceFile, bucketAliases, bucketsById, resolveCtx)
72
65
  for (const [name, block] of next) {
73
66
  blockAliases.set(name, block)
74
67
  }
75
68
  }
76
69
 
77
- const models: ModelAst[] = []
70
+ const models: ExtractedSchemaAstV2["models"] = []
78
71
 
79
72
  for (const sourceFile of sourceFiles) {
80
73
  for (const stmt of sourceFile.statements) {
81
74
  if (!ts.isTypeAliasDeclaration(stmt)) continue
82
75
  if (!hasExportModifier(stmt)) continue
83
76
  if (!ts.isTypeReferenceNode(stmt.type)) continue
84
- if (stmt.type.typeName.getText(sourceFile) !== "Model") continue
77
+ const modelTypeName = stmt.type.typeName.getText(sourceFile)
78
+ if (modelTypeName !== "Model" && modelTypeName !== "LocalizedModel") continue
85
79
  const [fieldsArg, metaArg] = stmt.type.typeArguments ?? []
86
80
  if (!fieldsArg) continue
87
- const fieldsLiteral = unwrapModelFields(fieldsArg)
81
+ const fieldsLiteral = unwrapModelFields(fieldsArg, sourceFile, resolveCtx)
88
82
  if (!fieldsLiteral) continue
89
83
 
90
- const fields: Record<string, FieldAst> = {}
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> = {}
91
90
  for (const member of fieldsLiteral.members) {
92
91
  if (!ts.isPropertySignature(member) || !member.type) continue
93
92
  const name = getPropertyName(member.name)
@@ -99,17 +98,22 @@ export function extractSchemaAstFromTypes(
99
98
  blockAliases,
100
99
  bucketAliases,
101
100
  bucketsById,
101
+ fieldContext,
102
+ resolveCtx,
102
103
  )
103
104
  }
104
105
 
105
- models.push({
106
- name: stmt.name.text,
107
- tableName: toSnakeCase(stmt.name.text),
106
+ const { tableName, access, options } = parseModelMeta(
107
+ metaArg,
108
+ sourceFile,
109
+ stmt.name.text,
110
+ fieldsArg,
108
111
  fields,
109
- access: parseModelAccess(metaArg, sourceFile),
110
- indexes: [],
111
- options: {},
112
- })
112
+ )
113
+
114
+ models.push(
115
+ emitModel(stmt.name.text, fields, options, tableName, access),
116
+ )
113
117
  }
114
118
  }
115
119
 
@@ -118,10 +122,25 @@ export function extractSchemaAstFromTypes(
118
122
  const storageBuckets =
119
123
  bucketsById.size > 0 ? [...bucketsById.values()].sort((a, b) => a.id.localeCompare(b.id)) : undefined
120
124
 
121
- return {
122
- models,
123
- ...(storageBuckets !== undefined && storageBuckets.length > 0 && { storageBuckets }),
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
124
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
+ })
125
144
  }
126
145
 
127
146
  function loadSchemaSourceFiles(entryPath: string): ts.SourceFile[] {
@@ -142,9 +161,18 @@ function loadSchemaSourceFiles(entryPath: string): ts.SourceFile[] {
142
161
 
143
162
  const baseDir = dirname(currentPath)
144
163
  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)
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)
148
176
  if (!nextPath) continue
149
177
  if (!visited.has(nextPath)) queue.push(nextPath)
150
178
  }
@@ -188,24 +216,69 @@ function getPropertyName(name: ts.PropertyName): string | null {
188
216
  return null
189
217
  }
190
218
 
191
- function unwrapModelFields(typeNode: ts.TypeNode): ts.TypeLiteralNode | null {
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
192
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
+
193
234
  if (!ts.isTypeReferenceNode(typeNode) || !ts.isIdentifier(typeNode.typeName)) return null
194
235
 
236
+ const typeName = applyImportRename(typeNode.typeName.text, sourceFile, resolveCtx.renameMap)
237
+
195
238
  // Composite helpers in @supatype/types wrap the concrete field object.
196
239
  if (
197
- typeNode.typeName.text === "WithTimestamps" ||
198
- typeNode.typeName.text === "WithSoftDelete" ||
199
- typeNode.typeName.text === "WithPublishable"
240
+ typeName === "WithTimestamps" ||
241
+ typeName === "WithSoftDelete" ||
242
+ typeName === "WithPublishable"
200
243
  ) {
201
244
  const inner = typeNode.typeArguments?.[0]
202
245
  if (!inner) return null
203
- return unwrapModelFields(inner)
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)
204
253
  }
205
254
 
206
255
  return null
207
256
  }
208
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
+
209
282
  function parseFieldType(
210
283
  fieldName: string,
211
284
  typeNode: ts.TypeNode,
@@ -213,7 +286,9 @@ function parseFieldType(
213
286
  blockAliases: Map<string, BlockDefinitionAst>,
214
287
  bucketAliases: Map<string, string>,
215
288
  bucketsById: Map<string, ExtractedStorageBucketAst>,
216
- ): FieldAst {
289
+ context: FieldParseContext = {},
290
+ resolveCtx: ResolveContext,
291
+ ): FieldAstV2 {
217
292
  const flags = {
218
293
  required: true,
219
294
  unique: false,
@@ -224,15 +299,17 @@ function parseFieldType(
224
299
  relationCardinality: undefined as "one" | "many" | undefined,
225
300
  relationTarget: undefined as string | undefined,
226
301
  editorReadOnly: false,
227
- /** When set from `ComputedFrom`, Studio previews from these sources until edited on create */
228
302
  computedFromSources: undefined as string[] | undefined,
229
- /** When set, second arg was a template literal with `{field}` / `{truncate(f, n)}` */
230
303
  computedFromTemplate: undefined as string | undefined,
304
+ fieldDefault: undefined as string | number | boolean | null | undefined,
305
+ localized: false,
306
+ notLocalized: false,
231
307
  }
232
308
 
309
+ const resolving = new Set<string>()
233
310
  let current = typeNode
234
311
  while (ts.isTypeReferenceNode(current) && ts.isIdentifier(current.typeName)) {
235
- const typeName = current.typeName.text
312
+ const typeName = applyImportRename(current.typeName.text, sourceFile, resolveCtx.renameMap)
236
313
  switch (typeName) {
237
314
  case "Optional":
238
315
  flags.required = false
@@ -261,10 +338,18 @@ function parseFieldType(
261
338
  flags.unique = true
262
339
  current = current.typeArguments?.[0] ?? current
263
340
  continue
264
- case "Default":
265
- // Default<T, V> — unwrap to T so `Default<boolean, true>` resolves as boolean, not text.
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.
266
350
  current = current.typeArguments?.[0] ?? current
267
351
  continue
352
+ }
268
353
  case "Searchable":
269
354
  current = current.typeArguments?.[0] ?? current
270
355
  continue
@@ -295,251 +380,438 @@ function parseFieldType(
295
380
  case "Between":
296
381
  current = current.typeArguments?.[0] ?? current
297
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
298
391
  case "RelatedTo":
299
392
  flags.relationCardinality = "one"
300
393
  flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
301
394
  // `target` must match `ModelAst.name` to satisfy validator resolution.
302
395
  // FK column follows the field name (two relations to the same model need distinct columns).
303
- return {
396
+ return emitField({
304
397
  kind: "relation",
305
- cardinality: "belongsTo",
306
- target: flags.relationTarget,
307
- foreignKey: relationForeignKeyFromField(fieldName),
308
- ...(flags.editorReadOnly && { readOnly: true }),
309
- }
398
+ kernel: { cardinality: "belongsTo", target: flags.relationTarget! },
399
+ db: { foreignKey: relationForeignKeyFromField(fieldName) },
400
+ platform: flags.editorReadOnly ? { readOnly: true } : {},
401
+ })
310
402
  case "HasOne":
311
403
  flags.relationCardinality = "one"
312
404
  flags.relationTarget = current.typeArguments?.[0]?.getText(sourceFile).replace(/\W/g, "") ?? "unknown"
313
- return {
405
+ return emitField({
314
406
  kind: "relation",
315
- cardinality: "hasOne",
316
- target: flags.relationTarget,
317
- ...(flags.editorReadOnly && { readOnly: true }),
318
- }
407
+ kernel: { cardinality: "hasOne", target: flags.relationTarget },
408
+ db: {},
409
+ platform: flags.editorReadOnly ? { readOnly: true } : {},
410
+ })
319
411
  case "HasMany":
320
412
  case "ManyToMany":
321
413
  flags.relationCardinality = "many"
322
414
  flags.relationTarget = current.typeArguments?.[0]?.getText(sourceFile).replace(/\W/g, "") ?? "unknown"
323
- return {
415
+ return emitField({
324
416
  kind: "relation",
325
- cardinality: "hasMany",
326
- target: flags.relationTarget,
327
- ...(flags.editorReadOnly && { readOnly: true }),
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
328
426
  }
329
- default:
330
427
  break
428
+ }
331
429
  }
332
430
  break
333
431
  }
334
432
 
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 }),
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
+ },
343
461
  }
344
462
 
345
463
  if (flags.autoIncrement && parsed.kind === "integer") {
346
- parsed.kind = "serial"
347
- parsed.pgType = "SERIAL"
464
+ parsed = { ...parsed, kind: "serial", db: { ...parsed.db, pgType: "SERIAL" } }
348
465
  }
349
466
 
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
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
+ }
360
485
  }
361
486
 
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")) {
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
+ ) {
366
496
  flags.serverGenerated = true
367
497
  }
368
498
 
369
499
  if (flags.serverGenerated === true) {
370
- parsed.serverGenerated = true
500
+ parsed = { ...parsed, db: { ...parsed.db, serverGenerated: true } }
371
501
  }
372
502
 
373
- // Convention: standard audit columns are filled by the DB on insert/update.
374
503
  const auditTs =
375
504
  fieldName === "created_at" ||
376
505
  fieldName === "updated_at" ||
377
506
  fieldName === "createdAt" ||
378
507
  fieldName === "updatedAt"
379
508
  if (auditTs) {
380
- parsed.serverGenerated = true
509
+ parsed = { ...parsed, db: { ...parsed.db, serverGenerated: true } }
381
510
  if (
382
511
  (parsed.kind === "datetime" || parsed.kind === "date") &&
383
- parsed.default === undefined
512
+ parsed.kernel.default === undefined
384
513
  ) {
385
- parsed.default = { kind: "now" }
514
+ parsed = { ...parsed, kernel: { ...parsed.kernel, default: { kind: "now" } } }
386
515
  }
387
516
  }
388
517
 
389
- // `ServerDefault<Date>` etc. → DEFAULT NOW() for column types Postgres handles with NOW().
390
518
  if (
391
519
  flags.serverGenerated &&
392
520
  (parsed.kind === "datetime" || parsed.kind === "date") &&
393
- parsed.default === undefined
521
+ parsed.kernel.default === undefined
394
522
  ) {
395
- parsed.default = { kind: "now" }
523
+ parsed = { ...parsed, kernel: { ...parsed.kernel, default: { kind: "now" } } }
396
524
  }
397
525
 
398
526
  const hasCfTemplate = flags.computedFromTemplate !== undefined
399
527
  const hasCfSources = Boolean(flags.computedFromSources && flags.computedFromSources.length > 0)
400
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) {
401
559
  return {
402
560
  ...parsed,
403
- ...(hasCfSources && { sources: flags.computedFromSources! }),
404
- ...(hasCfTemplate && { template: flags.computedFromTemplate }),
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
+ },
405
573
  }
406
574
  }
407
575
 
576
+ if (localized) {
577
+ return {
578
+ ...parsed,
579
+ kernel: { ...parsed.kernel, localized: true },
580
+ db: { ...parsed.db, pgType: "JSONB" },
581
+ }
582
+ }
408
583
  return parsed
409
584
  }
410
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
+
411
604
  function parseScalarType(
412
605
  typeNode: ts.TypeNode,
413
606
  sourceFile: ts.SourceFile,
414
607
  blockAliases: Map<string, BlockDefinitionAst>,
415
608
  bucketAliases: Map<string, string>,
416
609
  bucketsById: Map<string, ExtractedStorageBucketAst>,
417
- ): FieldAst {
610
+ context: FieldParseContext = {},
611
+ resolveCtx: ResolveContext,
612
+ fieldName = "?",
613
+ resolving: Set<string> = new Set(),
614
+ ): ParsedField {
418
615
  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
- }
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
+ })
427
630
  }
428
631
 
429
632
  if (ts.isUnionTypeNode(typeNode)) {
430
633
  const literals = typeNode.types.filter(ts.isLiteralTypeNode)
431
634
  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
- }
635
+ return scalar("enum", {
636
+ kernel: {
637
+ values: literals.map((lit) => (lit.literal as ts.StringLiteral).text),
638
+ },
639
+ })
437
640
  }
438
641
  const nonNull = typeNode.types.find((t) => t.kind !== ts.SyntaxKind.NullKeyword)
439
- if (nonNull) return parseScalarType(nonNull, sourceFile, blockAliases, bucketAliases, bucketsById)
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
+ }
440
672
  }
441
673
 
442
674
  if (ts.isTypeReferenceNode(typeNode)) {
443
- const ref = typeNode.typeName.getText(sourceFile)
675
+ const ref = ts.isIdentifier(typeNode.typeName)
676
+ ? applyImportRename(typeNode.typeName.text, sourceFile, resolveCtx.renameMap)
677
+ : typeNode.typeName.getText(sourceFile)
444
678
  switch (ref) {
445
679
  case "UUID":
446
680
  case "SupatypeAuthUserId":
447
- return { kind: "uuid", pgType: "UUID" }
448
- case "RichText":
449
- return { kind: "richText", pgType: "JSONB" }
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
+ }
450
700
  case "Slug": {
451
701
  const fromArg = typeNode.typeArguments?.[0]
452
702
  const fromLiteral = fromArg ? literalStringType(fromArg) : null
453
- const from = fromLiteral ?? "title"
454
- return { kind: "slug", pgType: "TEXT", from }
703
+ return scalar("slug", { kernel: { from: fromLiteral ?? "title" } })
455
704
  }
456
705
  case "Email":
457
- return { kind: "email", pgType: "TEXT" }
706
+ return scalar("email")
458
707
  case "URL":
459
- return { kind: "url", pgType: "TEXT" }
708
+ return scalar("url")
460
709
  case "Markdown":
461
- return { kind: "text", pgType: "TEXT" }
462
- case "Color":
463
- return { kind: "color", pgType: "TEXT" }
464
710
  case "PhoneNumber":
465
- return { kind: "text", pgType: "TEXT" }
711
+ return scalar("text")
712
+ case "Color":
713
+ return scalar("color")
466
714
  case "IPAddress":
467
- return { kind: "ip", pgType: "TEXT" }
715
+ return scalar("ip")
468
716
  case "CIDR":
469
- return { kind: "cidr", pgType: "TEXT" }
717
+ return scalar("cidr")
470
718
  case "MacAddress":
471
- return { kind: "macaddr", pgType: "TEXT" }
719
+ return scalar("macaddr")
472
720
  case "XML":
473
- return { kind: "xml", pgType: "TEXT" }
721
+ return scalar("xml")
474
722
  case "TSQuery":
475
- return { kind: "tsQuery", pgType: "TEXT" }
723
+ return scalar("tsQuery")
476
724
  case "TSVector":
477
- return { kind: "tsVector", pgType: "TEXT" }
725
+ return scalar("tsVector")
478
726
  case "Money":
479
- return { kind: "money", pgType: "TEXT" }
727
+ return scalar("money")
480
728
  case "Decimal":
481
- return { kind: "decimal", pgType: "TEXT" }
729
+ return scalar("decimal")
482
730
  case "DateOnly":
483
- return { kind: "date", pgType: "DATE" }
731
+ return scalar("date")
484
732
  case "Date":
485
733
  case "DateTime":
486
734
  case "Timestamp":
487
- return { kind: "datetime", pgType: "TIMESTAMP WITH TIME ZONE" }
735
+ return scalar("datetime", { db: { pgType: "TIMESTAMP WITH TIME ZONE" } })
488
736
  case "Int":
489
- return { kind: "integer", pgType: "INTEGER" }
737
+ return scalar("integer")
490
738
  case "SmallInt":
491
- return { kind: "smallInt", pgType: "SMALLINT" }
739
+ return scalar("smallInt")
492
740
  case "BigInt":
493
- return { kind: "bigInt", pgType: "BIGINT" }
741
+ return scalar("bigInt")
494
742
  case "Float":
495
- return { kind: "float", pgType: "DOUBLE PRECISION" }
743
+ return scalar("float")
496
744
  case "Bytea":
497
- return { kind: "bytes", pgType: "BYTEA" }
745
+ return scalar("bytes")
498
746
  case "JSON":
499
- return { kind: "json", pgType: "JSONB" }
747
+ return scalar("json")
748
+ case "Button":
749
+ return scalar("button", { db: { pgType: "JSONB" } })
750
+ case "Duration":
751
+ return scalar("json", { db: { pgType: "JSONB" } })
500
752
  case "GeoPoint":
501
- return { kind: "geo", pgType: "GEOGRAPHY", geoType: "point", srid: 4326 }
502
753
  case "Geo":
503
- return { kind: "geo", pgType: "GEOGRAPHY", geoType: "point", srid: 4326 }
754
+ return scalar("geo", { kernel: { geoType: "point", srid: 4326 } })
504
755
  case "Asset":
505
756
  case "FileAsset": {
506
757
  const bucket = resolveBucketName(typeNode.typeArguments?.[0], sourceFile, bucketAliases, "assets")
507
- return attachStorageFieldMeta({ kind: "file", pgType: "TEXT", bucket }, bucket, bucketsById)
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
+ )
508
767
  }
509
768
  case "ImageAsset": {
510
769
  const bucket = resolveBucketName(typeNode.typeArguments?.[0], sourceFile, bucketAliases, "images")
511
- return attachStorageFieldMeta({ kind: "image", pgType: "TEXT", bucket }, bucket, bucketsById)
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
+ )
512
779
  }
513
780
  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
- }
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
+ })
525
795
  case "Vector": {
526
796
  const dimensions = typeNode.typeArguments?.[0]?.getText(sourceFile)
527
- return { kind: "vector", pgType: "VECTOR", dimensions: Number(dimensions ?? "1536") }
797
+ return scalar("vector", {
798
+ kernel: { dimensions: Number(dimensions ?? "1536") },
799
+ })
528
800
  }
529
801
  default:
530
- return { kind: "text", pgType: "TEXT" }
802
+ throw unknownTypeError(ref, fieldName)
531
803
  }
532
804
  }
533
805
 
534
806
  switch (typeNode.kind) {
535
807
  case ts.SyntaxKind.StringKeyword:
536
- return { kind: "text", pgType: "TEXT" }
808
+ return scalar("text")
537
809
  case ts.SyntaxKind.NumberKeyword:
538
- return { kind: "float", pgType: "DOUBLE PRECISION" }
810
+ return scalar("float")
539
811
  case ts.SyntaxKind.BooleanKeyword:
540
- return { kind: "boolean", pgType: "BOOLEAN" }
812
+ return scalar("boolean")
541
813
  default:
542
- return { kind: "json", pgType: "JSONB" }
814
+ return scalar("json")
543
815
  }
544
816
  }
545
817
 
@@ -547,19 +819,71 @@ function collectBlockAliases(
547
819
  sourceFile: ts.SourceFile,
548
820
  bucketAliases: Map<string, string>,
549
821
  bucketsById: Map<string, ExtractedStorageBucketAst>,
822
+ resolveCtx: ResolveContext,
550
823
  ): Map<string, BlockDefinitionAst> {
551
824
  const blocks = new Map<string, BlockDefinitionAst>()
552
825
  for (const stmt of sourceFile.statements) {
553
826
  if (!ts.isTypeAliasDeclaration(stmt)) continue
554
827
  if (!ts.isTypeReferenceNode(stmt.type)) continue
555
828
  if (!ts.isIdentifier(stmt.type.typeName) || stmt.type.typeName.text !== "Block") continue
556
- const block = parseInlineBlockDefinition(stmt.type, sourceFile, new Map(), bucketAliases, bucketsById)
829
+ const block = parseInlineBlockDefinition(
830
+ stmt.type,
831
+ sourceFile,
832
+ new Map(),
833
+ bucketAliases,
834
+ bucketsById,
835
+ {},
836
+ resolveCtx,
837
+ )
557
838
  if (!block) continue
558
839
  blocks.set(stmt.name.text, block)
559
840
  }
560
841
  return blocks
561
842
  }
562
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
+
563
887
  function collectBucketContext(sourceFile: ts.SourceFile): {
564
888
  aliases: Map<string, string>
565
889
  bucketsById: Map<string, ExtractedStorageBucketAst>
@@ -761,15 +1085,15 @@ function bucketsEqual(a: ExtractedStorageBucketAst, b: ExtractedStorageBucketAst
761
1085
  }
762
1086
 
763
1087
  function attachStorageFieldMeta(
764
- field: FieldAst,
1088
+ field: ParsedField,
765
1089
  bucketId: string,
766
1090
  bucketsById: Map<string, ExtractedStorageBucketAst>,
767
- ): FieldAst {
1091
+ ): ParsedField {
768
1092
  const cfg = bucketsById.get(bucketId)
769
1093
  if (cfg?.accessMode !== undefined) {
770
1094
  return {
771
1095
  ...field,
772
- ...(cfg.accessMode !== undefined && { accessMode: cfg.accessMode }),
1096
+ kernel: { ...field.kernel, accessMode: cfg.accessMode },
773
1097
  }
774
1098
  }
775
1099
  return field
@@ -781,6 +1105,8 @@ function parseBlocksTypeDefinitions(
781
1105
  blockAliases: Map<string, BlockDefinitionAst>,
782
1106
  bucketAliases: Map<string, string>,
783
1107
  bucketsById: Map<string, ExtractedStorageBucketAst>,
1108
+ context: FieldParseContext = {},
1109
+ resolveCtx: ResolveContext,
784
1110
  ): BlockDefinitionAst[] {
785
1111
  if (!blocksArg) return []
786
1112
  const parts = ts.isUnionTypeNode(blocksArg) ? blocksArg.types : [blocksArg]
@@ -788,7 +1114,15 @@ function parseBlocksTypeDefinitions(
788
1114
  for (const part of parts) {
789
1115
  if (ts.isTypeReferenceNode(part) && ts.isIdentifier(part.typeName)) {
790
1116
  if (part.typeName.text === "Block") {
791
- const inline = parseInlineBlockDefinition(part, sourceFile, blockAliases, bucketAliases, bucketsById)
1117
+ const inline = parseInlineBlockDefinition(
1118
+ part,
1119
+ sourceFile,
1120
+ blockAliases,
1121
+ bucketAliases,
1122
+ bucketsById,
1123
+ context,
1124
+ resolveCtx,
1125
+ )
792
1126
  if (inline) out.push(inline)
793
1127
  continue
794
1128
  }
@@ -805,12 +1139,14 @@ function parseInlineBlockDefinition(
805
1139
  blockAliases: Map<string, BlockDefinitionAst>,
806
1140
  bucketAliases: Map<string, string>,
807
1141
  bucketsById: Map<string, ExtractedStorageBucketAst>,
1142
+ context: FieldParseContext = {},
1143
+ resolveCtx: ResolveContext,
808
1144
  ): BlockDefinitionAst | null {
809
1145
  const [nameArg, fieldsArg, metaArg] = ref.typeArguments ?? []
810
1146
  const name = literalStringType(nameArg)
811
1147
  if (!name || !fieldsArg || !ts.isTypeLiteralNode(fieldsArg)) return null
812
1148
 
813
- const fields: Record<string, FieldAst> = {}
1149
+ const fields: Record<string, FieldAstV2> = {}
814
1150
  for (const member of fieldsArg.members) {
815
1151
  if (!ts.isPropertySignature(member) || !member.type) continue
816
1152
  const fieldName = getPropertyName(member.name)
@@ -822,6 +1158,8 @@ function parseInlineBlockDefinition(
822
1158
  blockAliases,
823
1159
  bucketAliases,
824
1160
  bucketsById,
1161
+ context,
1162
+ resolveCtx,
825
1163
  )
826
1164
  }
827
1165
 
@@ -937,6 +1275,131 @@ function resolveBucketName(
937
1275
  return typeArg.getText(sourceFile).replace(/^['"]|['"]$/g, "") || fallback
938
1276
  }
939
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
+
940
1403
  function parseModelAccess(metaArg: ts.TypeNode | undefined, sourceFile: ts.SourceFile): Record<string, unknown> {
941
1404
  if (!metaArg || !ts.isTypeLiteralNode(metaArg)) return {}
942
1405
  const accessProp = metaArg.members.find(
@@ -980,7 +1443,7 @@ function parseAccessRule(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): Reco
980
1443
  case "OwnerFrom": {
981
1444
  const relationArg = typeNode.typeArguments?.[0]
982
1445
  const relationField = relationArg?.getText(sourceFile).replace(/['"]/g, "") ?? "owner"
983
- return { type: "owner", field: relationForeignKeyFromField(relationField) }
1446
+ return { type: "owner", field: relationField }
984
1447
  }
985
1448
  case "Role": {
986
1449
  const roleArg = typeNode.typeArguments?.[0]