@supatype/cli 0.1.3 → 0.1.4

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.
@@ -265,6 +265,58 @@ function getPropertyName(name: ts.PropertyName): string | null {
265
265
  return null
266
266
  }
267
267
 
268
+ /**
269
+ * Known @supatype/types intersection mixins and the field source text they contribute.
270
+ * Used when a mixin type can't be resolved from the local alias registry
271
+ * (it comes from the external @supatype/types package, not a local file).
272
+ */
273
+ const KNOWN_MIXIN_SOURCES: Record<string, string> = {
274
+ Timestamps: "{ created_at: ServerDefault<Date>; updated_at: ServerDefault<Date> }",
275
+ SoftDelete: "{ deleted_at: Optional<Date> }",
276
+ Publishable: "{ published_at: Optional<Date> }",
277
+ }
278
+
279
+ function synthesizeTypeLiteralMembers(source: string): ts.TypeElement[] {
280
+ const synth = ts.createSourceFile(
281
+ "__synth__.ts",
282
+ `type __T__ = ${source}`,
283
+ ts.ScriptTarget.Latest,
284
+ true,
285
+ ts.ScriptKind.TS,
286
+ )
287
+ const decl = synth.statements[0]
288
+ if (!decl || !ts.isTypeAliasDeclaration(decl) || !ts.isTypeLiteralNode(decl.type)) return []
289
+ return [...decl.type.members]
290
+ }
291
+
292
+ function mergeIntersectionParts(
293
+ parts: readonly ts.TypeNode[],
294
+ sourceFile: ts.SourceFile,
295
+ resolveCtx: ResolveContext,
296
+ depth: number,
297
+ ): ts.TypeLiteralNode | null {
298
+ const allMembers: ts.TypeElement[] = []
299
+ for (const part of parts) {
300
+ const resolved = unwrapModelFields(part, sourceFile, resolveCtx, depth + 1)
301
+ if (resolved) {
302
+ allMembers.push(...resolved.members)
303
+ continue
304
+ }
305
+ // Fall back to known @supatype/types intersection mixins (Timestamps, SoftDelete, Publishable)
306
+ if (ts.isTypeReferenceNode(part) && ts.isIdentifier(part.typeName)) {
307
+ const typeName = applyImportRename(part.typeName.text, sourceFile, resolveCtx.renameMap)
308
+ const mixinSource = KNOWN_MIXIN_SOURCES[typeName]
309
+ if (mixinSource) {
310
+ allMembers.push(...synthesizeTypeLiteralMembers(mixinSource))
311
+ continue
312
+ }
313
+ }
314
+ // Unresolvable parts are skipped — the model still extracts with whatever fields were found
315
+ }
316
+ if (allMembers.length === 0) return null
317
+ return ts.factory.createTypeLiteralNode(allMembers)
318
+ }
319
+
268
320
  function unwrapModelFields(
269
321
  typeNode: ts.TypeNode,
270
322
  sourceFile: ts.SourceFile,
@@ -274,6 +326,11 @@ function unwrapModelFields(
274
326
  if (depth > 16) return null
275
327
  if (ts.isTypeLiteralNode(typeNode)) return typeNode
276
328
 
329
+ // Handle intersection types: `{ …fields } & Timestamps`, `{ …fields } & SoftDelete`, etc.
330
+ if (ts.isIntersectionTypeNode(typeNode)) {
331
+ return mergeIntersectionParts(typeNode.types, sourceFile, resolveCtx, depth)
332
+ }
333
+
277
334
  if (needsChecker(typeNode)) {
278
335
  const resolved = resolveTypeNode(typeNode, sourceFile, resolveCtx)
279
336
  if (ts.isTypeLiteralNode(resolved)) return resolved
@@ -581,6 +581,107 @@ export type Post = Model<WithTimestamps<{
581
581
  expect(post?.options.singleton).toBeUndefined()
582
582
  })
583
583
 
584
+ it("extracts models that use intersection `} & Timestamps` with relational fields", () => {
585
+ const dir = mkdtempSync(join(tmpdir(), "supatype-intersection-timestamps-"))
586
+ dirs.push(dir)
587
+ const schemaPath = join(dir, "schema.ts")
588
+ writeFileSync(
589
+ schemaPath,
590
+ `
591
+ import type {
592
+ Model, LoggedIn, Owner, Public, RelatedTo, Unique, Optional, MaxLength, Timestamps, UUID,
593
+ } from "@supatype/types"
594
+
595
+ export type Profile = Model<{
596
+ id: UUID
597
+ display_name: string
598
+ }, {
599
+ access: { read: LoggedIn; create: Owner<"id">; update: Owner<"id">; delete: Owner<"id"> }
600
+ }>
601
+
602
+ export type Room = Model<{
603
+ id: UUID
604
+ name: Unique<string>
605
+ topic: Optional<string>
606
+ created_by: RelatedTo<Profile, { required: true }>
607
+ } & Timestamps, {
608
+ access: { read: Public; create: LoggedIn; update: Owner<"created_by_id">; delete: Owner<"created_by_id"> }
609
+ }>
610
+
611
+ export type Message = Model<{
612
+ id: UUID
613
+ room: RelatedTo<Room, { required: true, onDelete: "cascade" }>
614
+ author: RelatedTo<Profile, { required: true }>
615
+ body: MaxLength<string, 2000>
616
+ } & Timestamps, {
617
+ access: { read: Public; create: LoggedIn; update: Owner<"author_id">; delete: Owner<"author_id"> }
618
+ indexes: [{ fields: ["room_id", "created_at"] }]
619
+ }>
620
+ `,
621
+ "utf8",
622
+ )
623
+
624
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
625
+ expect(ast).not.toBeNull()
626
+ expect(ast?.models).toHaveLength(3)
627
+
628
+ const room = ast?.models.find((m) => m.name === "Room")
629
+ const message = ast?.models.find((m) => m.name === "Message")
630
+
631
+ expect(room).toBeDefined()
632
+ expect(tableName(room)).toBe("room")
633
+ expect(room?.options.timestamps).toBe(true)
634
+ expect(room?.fields["created_by"]).toMatchObject({
635
+ kind: "relation",
636
+ cardinality: "belongsTo",
637
+ target: "Profile",
638
+ annotations: { db: { foreignKey: "created_by_id" } },
639
+ })
640
+ expect(room?.fields["created_at"]).toMatchObject({ kind: "datetime" })
641
+ expect(room?.fields["updated_at"]).toMatchObject({ kind: "datetime" })
642
+
643
+ expect(message).toBeDefined()
644
+ expect(tableName(message)).toBe("message")
645
+ expect(message?.options.timestamps).toBe(true)
646
+ expect(message?.fields["room"]).toMatchObject({
647
+ kind: "relation",
648
+ cardinality: "belongsTo",
649
+ target: "Room",
650
+ annotations: { db: { foreignKey: "room_id" } },
651
+ })
652
+ expect(message?.fields["author"]).toMatchObject({
653
+ kind: "relation",
654
+ cardinality: "belongsTo",
655
+ target: "Profile",
656
+ })
657
+ })
658
+
659
+ it("extracts models with `} & SoftDelete` intersection", () => {
660
+ const dir = mkdtempSync(join(tmpdir(), "supatype-intersection-softdelete-"))
661
+ dirs.push(dir)
662
+ const schemaPath = join(dir, "schema.ts")
663
+ writeFileSync(
664
+ schemaPath,
665
+ `
666
+ import type { Model, UUID, SoftDelete, Public, LoggedIn } from "@supatype/types"
667
+
668
+ export type Post = Model<{
669
+ id: UUID
670
+ title: string
671
+ } & SoftDelete, {
672
+ access: { read: Public; create: LoggedIn }
673
+ }>
674
+ `,
675
+ "utf8",
676
+ )
677
+
678
+ const ast = extractSchemaAstFromTypes(schemaPath, dir)
679
+ const post = ast?.models.find((m) => m.name === "Post")
680
+ expect(post).toBeDefined()
681
+ expect(post?.fields["deleted_at"]).toMatchObject({ kind: "datetime", required: false })
682
+ expect(post?.options.softDelete).toBe(true)
683
+ })
684
+
584
685
  it("extracts LocaleConfig into schema AST locales", () => {
585
686
  const dir = mkdtempSync(join(tmpdir(), "supatype-types-locale-config-"))
586
687
  dirs.push(dir)