@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +97 -97
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/type-extractor.js +44 -0
- package/dist/type-extractor.js.map +1 -1
- package/package.json +1 -1
- package/src/type-extractor.ts +57 -0
- package/tests/type-extractor.test.ts +101 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/type-extractor.ts
CHANGED
|
@@ -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)
|