@supatype/cli 0.1.4 → 0.1.6

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 (94) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +99 -97
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/app-vite-env.d.ts +4 -0
  5. package/dist/app-vite-env.d.ts.map +1 -0
  6. package/dist/app-vite-env.js +22 -0
  7. package/dist/app-vite-env.js.map +1 -0
  8. package/dist/augmentation-generator.d.ts.map +1 -1
  9. package/dist/augmentation-generator.js +26 -4
  10. package/dist/augmentation-generator.js.map +1 -1
  11. package/dist/binary-cache.d.ts +7 -5
  12. package/dist/binary-cache.d.ts.map +1 -1
  13. package/dist/binary-cache.js +28 -10
  14. package/dist/binary-cache.js.map +1 -1
  15. package/dist/cli-standalone.d.ts +10 -0
  16. package/dist/cli-standalone.d.ts.map +1 -0
  17. package/dist/cli-standalone.js +56 -0
  18. package/dist/cli-standalone.js.map +1 -0
  19. package/dist/commands/admin.d.ts.map +1 -1
  20. package/dist/commands/admin.js +10 -3
  21. package/dist/commands/admin.js.map +1 -1
  22. package/dist/commands/dev.d.ts.map +1 -1
  23. package/dist/commands/dev.js +34 -0
  24. package/dist/commands/dev.js.map +1 -1
  25. package/dist/commands/init.d.ts.map +1 -1
  26. package/dist/commands/init.js +3 -1
  27. package/dist/commands/init.js.map +1 -1
  28. package/dist/commands/keys.d.ts.map +1 -1
  29. package/dist/commands/keys.js +16 -0
  30. package/dist/commands/keys.js.map +1 -1
  31. package/dist/commands/self-update.d.ts +1 -1
  32. package/dist/commands/self-update.d.ts.map +1 -1
  33. package/dist/commands/self-update.js +33 -9
  34. package/dist/commands/self-update.js.map +1 -1
  35. package/dist/commands/update.d.ts.map +1 -1
  36. package/dist/commands/update.js +9 -1
  37. package/dist/commands/update.js.map +1 -1
  38. package/dist/components.d.ts +2 -2
  39. package/dist/components.d.ts.map +1 -1
  40. package/dist/components.js +2 -2
  41. package/dist/components.js.map +1 -1
  42. package/dist/dev-compose.d.ts.map +1 -1
  43. package/dist/dev-compose.js +122 -12
  44. package/dist/dev-compose.js.map +1 -1
  45. package/dist/project-config.d.ts +2 -0
  46. package/dist/project-config.d.ts.map +1 -1
  47. package/dist/project-config.js.map +1 -1
  48. package/dist/realtime-launch.d.ts +11 -0
  49. package/dist/realtime-launch.d.ts.map +1 -0
  50. package/dist/realtime-launch.js +39 -0
  51. package/dist/realtime-launch.js.map +1 -0
  52. package/dist/required-host-components.js +1 -1
  53. package/dist/required-host-components.js.map +1 -1
  54. package/dist/route-manifest.d.ts +3 -0
  55. package/dist/route-manifest.d.ts.map +1 -0
  56. package/dist/route-manifest.js +15 -0
  57. package/dist/route-manifest.js.map +1 -0
  58. package/dist/schema-push-lock.d.ts +17 -0
  59. package/dist/schema-push-lock.d.ts.map +1 -0
  60. package/dist/schema-push-lock.js +106 -0
  61. package/dist/schema-push-lock.js.map +1 -0
  62. package/dist/self-host-compose.d.ts.map +1 -1
  63. package/dist/self-host-compose.js +18 -0
  64. package/dist/self-host-compose.js.map +1 -1
  65. package/dist/type-extractor.d.ts.map +1 -1
  66. package/dist/type-extractor.js +125 -13
  67. package/dist/type-extractor.js.map +1 -1
  68. package/package.json +1 -1
  69. package/scripts/build-release-bundle.mjs +27 -0
  70. package/src/app-vite-env.ts +27 -0
  71. package/src/augmentation-generator.ts +29 -5
  72. package/src/binary-cache.ts +29 -11
  73. package/src/cli-standalone.ts +62 -0
  74. package/src/commands/admin.ts +13 -3
  75. package/src/commands/dev.ts +38 -0
  76. package/src/commands/init.ts +3 -1
  77. package/src/commands/keys.ts +16 -0
  78. package/src/commands/self-update.ts +34 -13
  79. package/src/commands/update.ts +9 -1
  80. package/src/components.ts +2 -2
  81. package/src/dev-compose.ts +146 -14
  82. package/src/project-config.ts +2 -0
  83. package/src/realtime-launch.ts +52 -0
  84. package/src/required-host-components.ts +1 -1
  85. package/src/route-manifest.ts +17 -0
  86. package/src/schema-push-lock.ts +131 -0
  87. package/src/self-host-compose.ts +18 -0
  88. package/src/type-extractor.ts +156 -13
  89. package/tests/augmentation-generator.test.ts +44 -4
  90. package/tests/realtime-launch.test.ts +25 -0
  91. package/tests/required-host-components.test.ts +2 -0
  92. package/tests/route-manifest.test.ts +29 -0
  93. package/tests/type-extractor.test.ts +52 -0
  94. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,52 @@
1
+ import { existsSync } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import { ensureBinary } from "./ensure-binary.js"
5
+ import type { SupatypeProjectConfig } from "./project-config.js"
6
+
7
+ export interface RealtimeLaunchSpec {
8
+ bin: string
9
+ args: string[]
10
+ }
11
+
12
+ const CLI_PKG_DIR = dirname(fileURLToPath(import.meta.url))
13
+
14
+ /** Monorepo contributor fallback: packages/realtime/dist/index.js relative to CLI package. */
15
+ function monorepoRealtimeEntry(): string | null {
16
+ const candidate = resolve(CLI_PKG_DIR, "../../realtime/dist/index.js")
17
+ return existsSync(candidate) ? candidate : null
18
+ }
19
+
20
+ /**
21
+ * Resolve how to spawn supatype-realtime for native dev.
22
+ * Order: overrides.realtime → CDN binary → node + monorepo dist.
23
+ */
24
+ export async function resolveRealtimeLaunch(
25
+ config: SupatypeProjectConfig,
26
+ cwd: string,
27
+ ): Promise<RealtimeLaunchSpec> {
28
+ const override = config.overrides?.realtime?.trim()
29
+ if (override) {
30
+ const resolved = resolve(cwd, override)
31
+ if (!existsSync(resolved)) {
32
+ throw new Error(`[overrides] realtime path does not exist: ${resolved}`)
33
+ }
34
+ if (resolved.endsWith(".js") || resolved.endsWith(".mjs") || resolved.endsWith(".cjs")) {
35
+ return { bin: process.execPath, args: [resolved] }
36
+ }
37
+ return { bin: resolved, args: [] }
38
+ }
39
+
40
+ try {
41
+ const bin = await ensureBinary("realtime", config)
42
+ return { bin, args: [] }
43
+ } catch {
44
+ const entry = monorepoRealtimeEntry()
45
+ if (entry) {
46
+ return { bin: process.execPath, args: [entry] }
47
+ }
48
+ throw new Error(
49
+ "realtime binary not available — run `supatype update` or set overrides.realtime in supatype.config.ts",
50
+ )
51
+ }
52
+ }
@@ -23,7 +23,7 @@ export function requiredHostComponents(
23
23
  return ["engine"]
24
24
  }
25
25
 
26
- const components: Component[] = ["engine", "server", "postgres"]
26
+ const components: Component[] = ["engine", "server", "postgres", "realtime"]
27
27
  const hasFunctions = functionsPathCandidatesFromProject(config, cwd).some((dir) =>
28
28
  existsSync(dir),
29
29
  )
@@ -0,0 +1,17 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
+
3
+ /** Merge fields into `.supatype/manifest.json` (creates file if missing). */
4
+ export function patchRouteManifest(
5
+ manifestPath: string,
6
+ patch: Record<string, unknown>,
7
+ ): void {
8
+ let manifest: Record<string, unknown> = {}
9
+ if (existsSync(manifestPath)) {
10
+ try {
11
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>
12
+ } catch {
13
+ manifest = {}
14
+ }
15
+ }
16
+ writeFileSync(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`, "utf8")
17
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Advisory lock held while compose schema push runs so realtime can skip WAL
3
+ * decoding without stopping the realtime process (self-host safe).
4
+ *
5
+ * Must stay in sync with:
6
+ * - packages/realtime/src/schema-push-lock.ts
7
+ * - supatype-schema-engine (pg_advisory_xact_lock on the same keys)
8
+ */
9
+
10
+ import { existsSync } from "node:fs"
11
+ import { resolve } from "node:path"
12
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process"
13
+ import type { SelfHostComposePaths } from "./self-host-compose.js"
14
+ import { readEnvValue } from "./env-file.js"
15
+
16
+ export const SCHEMA_PUSH_LOCK_CLASSID = 872014
17
+ export const SCHEMA_PUSH_LOCK_OBJID = 1
18
+
19
+ function composeBaseArgs(
20
+ paths: SelfHostComposePaths,
21
+ cwd: string,
22
+ composeProject: string,
23
+ ): string[] {
24
+ const envFile = resolve(cwd, ".env")
25
+ const args = ["compose", "-p", composeProject, "--project-directory", cwd, "-f", paths.composePath]
26
+ if (existsSync(envFile)) args.push("--env-file", envFile)
27
+ return args
28
+ }
29
+
30
+ function dbExecArgs(
31
+ paths: SelfHostComposePaths,
32
+ cwd: string,
33
+ composeProject: string,
34
+ psqlArgs: string[],
35
+ ): string[] {
36
+ const pass = readEnvValue(cwd, "POSTGRES_PASSWORD", "postgres")
37
+ const user = readEnvValue(cwd, "POSTGRES_USER", "supatype_admin")
38
+ const db = readEnvValue(cwd, "POSTGRES_DB", "supatype")
39
+ return [
40
+ ...composeBaseArgs(paths, cwd, composeProject),
41
+ "exec",
42
+ "-T",
43
+ "-e",
44
+ `PGPASSWORD=${pass}`,
45
+ "db",
46
+ "psql",
47
+ "-U",
48
+ user,
49
+ "-d",
50
+ db,
51
+ "-v",
52
+ "ON_ERROR_STOP=1",
53
+ ...psqlArgs,
54
+ ]
55
+ }
56
+
57
+ function schemaPushLockIsHeld(
58
+ paths: SelfHostComposePaths,
59
+ cwd: string,
60
+ composeProject: string,
61
+ ): boolean {
62
+ const args = dbExecArgs(paths, cwd, composeProject, [
63
+ "-tAc",
64
+ `SELECT EXISTS (
65
+ SELECT 1 FROM pg_locks
66
+ WHERE locktype = 'advisory'
67
+ AND classid = ${SCHEMA_PUSH_LOCK_CLASSID}
68
+ AND objid = ${SCHEMA_PUSH_LOCK_OBJID}
69
+ AND granted
70
+ )`,
71
+ ])
72
+ const result = spawnSync("docker", args, { cwd, encoding: "utf8" })
73
+ return result.status === 0 && result.stdout.trim() === "t"
74
+ }
75
+
76
+ /**
77
+ * Hold a session advisory lock for the duration of `fn` via a background
78
+ * `psql` inside the compose db container. Killing the holder releases the lock.
79
+ */
80
+ export async function withComposeSchemaPushLock<T>(
81
+ paths: SelfHostComposePaths,
82
+ cwd: string,
83
+ composeProject: string,
84
+ fn: () => Promise<T>,
85
+ ): Promise<T> {
86
+ const holdSql =
87
+ `SELECT pg_advisory_lock(${SCHEMA_PUSH_LOCK_CLASSID}, ${SCHEMA_PUSH_LOCK_OBJID});` +
88
+ ` SELECT pg_sleep(86400);`
89
+ const args = dbExecArgs(paths, cwd, composeProject, ["-c", holdSql])
90
+
91
+ let stderrBuf = ""
92
+ const holder: ChildProcess = spawn("docker", args, {
93
+ cwd,
94
+ stdio: ["ignore", "pipe", "pipe"],
95
+ })
96
+ holder.stderr?.on("data", (chunk: Buffer) => {
97
+ stderrBuf += chunk.toString()
98
+ })
99
+
100
+ const deadline = Date.now() + 30_000
101
+ while (Date.now() < deadline) {
102
+ if (holder.exitCode !== null) {
103
+ const err = stderrBuf.trim() || "lock holder exited"
104
+ throw new Error(`[supatype] Failed to acquire schema-push advisory lock: ${err}`)
105
+ }
106
+ if (schemaPushLockIsHeld(paths, cwd, composeProject)) break
107
+ await new Promise((r) => setTimeout(r, 200))
108
+ }
109
+ if (!schemaPushLockIsHeld(paths, cwd, composeProject)) {
110
+ holder.kill("SIGTERM")
111
+ throw new Error("[supatype] Timed out waiting for schema-push advisory lock")
112
+ }
113
+
114
+ try {
115
+ return await fn()
116
+ } finally {
117
+ if (holder.exitCode === null) {
118
+ holder.kill("SIGTERM")
119
+ await new Promise<void>((resolveDone) => {
120
+ const t = setTimeout(() => {
121
+ holder.kill("SIGKILL")
122
+ resolveDone()
123
+ }, 3000)
124
+ holder.once("exit", () => {
125
+ clearTimeout(t)
126
+ resolveDone()
127
+ })
128
+ })
129
+ }
130
+ }
131
+ }
@@ -383,6 +383,20 @@ ${dbPorts} volumes:
383
383
  db:
384
384
  condition: service_healthy
385
385
 
386
+ realtime:
387
+ image: \${SUPATYPE_REALTIME_IMAGE:-supatype/realtime:latest}
388
+ expose:
389
+ - "4000"
390
+ environment:
391
+ PORT: "4000"
392
+ DATABASE_URL: "postgresql://\${POSTGRES_USER:-supatype_admin}:\${POSTGRES_PASSWORD:-postgres}@db:5432/\${POSTGRES_DB:-supatype}"
393
+ JWT_SECRET: \${JWT_SECRET:-super-secret-jwt-token-change-in-production}
394
+ SLOT_NAME: supatype_realtime
395
+ PUBLICATION_NAME: supatype_realtime_pub
396
+ depends_on:
397
+ db:
398
+ condition: service_healthy
399
+
386
400
  control-plane:
387
401
  image: \${SUPATYPE_CONTROL_PLANE_IMAGE:-supatype/control-plane:latest}
388
402
  expose:
@@ -423,6 +437,7 @@ ${serverPorts} volumes:
423
437
  SUPATYPE_SQL_DATABASE_URL: "postgresql://\${POSTGRES_USER:-supatype_admin}:\${POSTGRES_PASSWORD:-postgres}@db:5432/\${POSTGRES_DB:-supatype}"
424
438
  SUPATYPE_DENO_FUNCTIONS_DIR: /project/functions
425
439
  SUPATYPE_FUNCTIONS_WORKER_URL: http://functions-worker:8001
440
+ SUPATYPE_REALTIME_URL: http://realtime:4000
426
441
  SUPATYPE_CONTROL_PLANE_URL: http://control-plane:8080
427
442
  SUPATYPE_VALKEY_ADDR: valkey:6379
428
443
  ${appEnv}
@@ -452,6 +467,8 @@ ${devLocal ? " STUDIO_OPEN_DEV: \"1\"\n" : ""}
452
467
  condition: service_started
453
468
  functions-worker:
454
469
  condition: service_started
470
+ realtime:
471
+ condition: service_started
455
472
  control-plane:
456
473
  condition: service_started
457
474
 
@@ -502,6 +519,7 @@ function ensureComposeManifest(cwd: string): void {
502
519
  postgrest_url: "http://postgrest:3000",
503
520
  storage_url: "http://storage:5000",
504
521
  realtime_enabled: true,
522
+ realtime_url: "http://realtime:4000",
505
523
  functions_enabled: false,
506
524
  }
507
525
  writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8")
@@ -22,6 +22,7 @@ import {
22
22
  type ExtractedSchemaAstV2,
23
23
  type ExtractedStorageBucketAst,
24
24
  type FieldAstV2,
25
+ type KernelFieldFacts,
25
26
  type ParsedField,
26
27
  } from "./schema-ast-v2.js"
27
28
 
@@ -410,6 +411,7 @@ function parseFieldType(
410
411
  fieldDefault: undefined as string | number | boolean | null | undefined,
411
412
  localized: false,
412
413
  notLocalized: false,
414
+ checkConstraint: undefined as string | undefined,
413
415
  }
414
416
 
415
417
  const resolving = new Set<string>()
@@ -481,11 +483,46 @@ function parseFieldType(
481
483
  current = valueArg ?? current
482
484
  continue
483
485
  }
484
- case "MaxLength":
485
- case "MinLength":
486
- case "Between":
486
+ case "MaxLength": {
487
+ const max = parseNumericTypeArg(current.typeArguments?.[1], sourceFile)
488
+ if (max !== undefined) {
489
+ flags.checkConstraint = mergeCheckConstraint(
490
+ flags.checkConstraint,
491
+ `char_length("{name}") <= ${max}`,
492
+ )
493
+ }
494
+ current = current.typeArguments?.[0] ?? current
495
+ continue
496
+ }
497
+ case "MinLength": {
498
+ const min = parseNumericTypeArg(current.typeArguments?.[1], sourceFile)
499
+ if (min !== undefined) {
500
+ flags.checkConstraint = mergeCheckConstraint(
501
+ flags.checkConstraint,
502
+ `char_length("{name}") >= ${min}`,
503
+ )
504
+ }
505
+ current = current.typeArguments?.[0] ?? current
506
+ continue
507
+ }
508
+ case "Between": {
509
+ const min = parseNumericTypeArg(current.typeArguments?.[1], sourceFile)
510
+ const max = parseNumericTypeArg(current.typeArguments?.[2], sourceFile)
511
+ if (min !== undefined) {
512
+ flags.checkConstraint = mergeCheckConstraint(
513
+ flags.checkConstraint,
514
+ `"{name}"::numeric >= ${min}`,
515
+ )
516
+ }
517
+ if (max !== undefined) {
518
+ flags.checkConstraint = mergeCheckConstraint(
519
+ flags.checkConstraint,
520
+ `"{name}"::numeric <= ${max}`,
521
+ )
522
+ }
487
523
  current = current.typeArguments?.[0] ?? current
488
524
  continue
525
+ }
489
526
  case "Localized":
490
527
  flags.localized = true
491
528
  current = current.typeArguments?.[0] ?? current
@@ -494,36 +531,68 @@ function parseFieldType(
494
531
  flags.notLocalized = true
495
532
  current = current.typeArguments?.[0] ?? current
496
533
  continue
497
- case "RelatedTo":
534
+ case "RelatedTo": {
498
535
  flags.relationCardinality = "one"
499
536
  flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
537
+ const relOpts = parseRelationOptions(current.typeArguments?.[1], sourceFile)
500
538
  // `target` must match `ModelAst.name` to satisfy validator resolution.
501
539
  // FK column follows the field name (two relations to the same model need distinct columns).
502
540
  return emitField({
503
541
  kind: "relation",
504
- kernel: { cardinality: "belongsTo", target: flags.relationTarget! },
542
+ kernel: {
543
+ cardinality: "belongsTo",
544
+ target: flags.relationTarget!,
545
+ ...relationOptionsKernel(relOpts),
546
+ },
505
547
  db: { foreignKey: relationForeignKeyFromField(fieldName) },
506
548
  platform: flags.editorReadOnly ? { readOnly: true } : {},
507
549
  })
508
- case "HasOne":
550
+ }
551
+ case "HasOne": {
509
552
  flags.relationCardinality = "one"
510
- flags.relationTarget = current.typeArguments?.[0]?.getText(sourceFile).replace(/\W/g, "") ?? "unknown"
553
+ flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
554
+ const relOpts = parseRelationOptions(current.typeArguments?.[1], sourceFile)
555
+ return emitField({
556
+ kind: "relation",
557
+ kernel: {
558
+ cardinality: "hasOne",
559
+ target: flags.relationTarget,
560
+ ...relationOptionsKernel(relOpts),
561
+ },
562
+ db: {},
563
+ platform: flags.editorReadOnly ? { readOnly: true } : {},
564
+ })
565
+ }
566
+ case "HasMany": {
567
+ flags.relationCardinality = "many"
568
+ flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
569
+ const relOpts = parseRelationOptions(current.typeArguments?.[1], sourceFile)
511
570
  return emitField({
512
571
  kind: "relation",
513
- kernel: { cardinality: "hasOne", target: flags.relationTarget },
572
+ kernel: {
573
+ cardinality: "hasMany",
574
+ target: flags.relationTarget,
575
+ ...relationOptionsKernel(relOpts),
576
+ },
514
577
  db: {},
515
578
  platform: flags.editorReadOnly ? { readOnly: true } : {},
516
579
  })
517
- case "HasMany":
518
- case "ManyToMany":
580
+ }
581
+ case "ManyToMany": {
519
582
  flags.relationCardinality = "many"
520
- flags.relationTarget = current.typeArguments?.[0]?.getText(sourceFile).replace(/\W/g, "") ?? "unknown"
583
+ flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
584
+ const relOpts = parseRelationOptions(current.typeArguments?.[1], sourceFile)
521
585
  return emitField({
522
586
  kind: "relation",
523
- kernel: { cardinality: "hasMany", target: flags.relationTarget },
587
+ kernel: {
588
+ cardinality: "manyToMany",
589
+ target: flags.relationTarget,
590
+ ...relationOptionsKernel(relOpts),
591
+ },
524
592
  db: {},
525
593
  platform: flags.editorReadOnly ? { readOnly: true } : {},
526
594
  })
595
+ }
527
596
  default: {
528
597
  const resolved = tryResolveTypeReference(current, sourceFile, resolveCtx, { fieldName, resolving })
529
598
  if (resolved) {
@@ -642,6 +711,13 @@ function parseFieldType(
642
711
  parsed = { ...parsed, kernel }
643
712
  }
644
713
 
714
+ if (flags.checkConstraint !== undefined) {
715
+ parsed = {
716
+ ...parsed,
717
+ kernel: { ...parsed.kernel, check: flags.checkConstraint },
718
+ }
719
+ }
720
+
645
721
  return emitField(finalizeParsedField(parsed, flags, context))
646
722
  }
647
723
 
@@ -1534,7 +1610,7 @@ function parseModelIndexes(
1534
1610
  const indexes: unknown[] = []
1535
1611
  for (const element of indexesProp.type.elements) {
1536
1612
  if (!ts.isTypeLiteralNode(element)) continue
1537
- const indexDef: Record<string, unknown> = { using: "btree" }
1613
+ const indexDef: Record<string, unknown> = { using: "btree", unique: false }
1538
1614
  for (const member of element.members) {
1539
1615
  if (!ts.isPropertySignature(member) || !member.type) continue
1540
1616
  const key = getPropertyName(member.name)
@@ -1628,6 +1704,73 @@ function parseAccessRule(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): Reco
1628
1704
  }
1629
1705
  }
1630
1706
 
1707
+ interface ParsedRelationOptions {
1708
+ required?: boolean
1709
+ onDelete?: string
1710
+ onUpdate?: string
1711
+ through?: string
1712
+ }
1713
+
1714
+ function parseRelationOptions(
1715
+ optsArg: ts.TypeNode | undefined,
1716
+ sourceFile: ts.SourceFile,
1717
+ ): ParsedRelationOptions {
1718
+ const out: ParsedRelationOptions = {}
1719
+ if (!optsArg || !ts.isTypeLiteralNode(optsArg)) return out
1720
+
1721
+ for (const member of optsArg.members) {
1722
+ if (!ts.isPropertySignature(member) || !member.name || !member.type) continue
1723
+ const key = getPropertyName(member.name)
1724
+ if (!key) continue
1725
+
1726
+ if (key === "required" && isBooleanLiteralType(member.type, true)) {
1727
+ out.required = true
1728
+ continue
1729
+ }
1730
+
1731
+ if (
1732
+ (key === "onDelete" || key === "onUpdate" || key === "through") &&
1733
+ ts.isLiteralTypeNode(member.type) &&
1734
+ ts.isStringLiteral(member.type.literal)
1735
+ ) {
1736
+ out[key] = member.type.literal.text
1737
+ }
1738
+ }
1739
+
1740
+ return out
1741
+ }
1742
+
1743
+ function relationOptionsKernel(
1744
+ opts: ParsedRelationOptions,
1745
+ ): Pick<KernelFieldFacts, "required" | "onDelete" | "onUpdate" | "through"> {
1746
+ return {
1747
+ ...(opts.required === true && { required: true }),
1748
+ ...(opts.onDelete !== undefined && { onDelete: opts.onDelete }),
1749
+ ...(opts.onUpdate !== undefined && { onUpdate: opts.onUpdate }),
1750
+ ...(opts.through !== undefined && { through: opts.through }),
1751
+ }
1752
+ }
1753
+
1754
+ function parseNumericTypeArg(typeArg: ts.TypeNode | undefined, sourceFile: ts.SourceFile): number | undefined {
1755
+ if (!typeArg) return undefined
1756
+ if (ts.isLiteralTypeNode(typeArg) && ts.isNumericLiteral(typeArg.literal)) {
1757
+ const value = Number(typeArg.literal.text)
1758
+ return Number.isFinite(value) ? value : undefined
1759
+ }
1760
+ if (ts.isLiteralTypeNode(typeArg) && ts.isStringLiteral(typeArg.literal)) {
1761
+ const value = Number(typeArg.literal.text)
1762
+ return Number.isFinite(value) ? value : undefined
1763
+ }
1764
+ const raw = typeArg.getText(sourceFile).trim()
1765
+ const value = Number(raw)
1766
+ return Number.isFinite(value) ? value : undefined
1767
+ }
1768
+
1769
+ function mergeCheckConstraint(existing: string | undefined, next: string): string {
1770
+ if (!existing) return next
1771
+ return `(${existing}) AND (${next})`
1772
+ }
1773
+
1631
1774
  function relationTargetFromTypeArg(typeArg: ts.TypeNode | undefined, sourceFile: ts.SourceFile): string {
1632
1775
  if (!typeArg) return "unknown"
1633
1776
  const raw = typeArg.getText(sourceFile).replace(/\s/g, "")
@@ -5,18 +5,58 @@ describe("generateClientAugmentation", () => {
5
5
  it("emits deterministic output independent of model order", () => {
6
6
  const astA = {
7
7
  models: [
8
- { tableName: "post", fields: { title: { kind: "text", required: true } } },
9
- { tableName: "comment", fields: { body: { kind: "text", required: true } } },
8
+ {
9
+ name: "Post",
10
+ fields: { title: { kind: "text", required: true } },
11
+ annotations: { db: { tableName: "post", indexes: [] } },
12
+ },
13
+ {
14
+ name: "Comment",
15
+ fields: { body: { kind: "text", required: true } },
16
+ annotations: { db: { tableName: "comment", indexes: [] } },
17
+ },
10
18
  ],
11
19
  }
12
20
  const astB = {
13
21
  models: [
14
- { tableName: "comment", fields: { body: { kind: "text", required: true } } },
15
- { tableName: "post", fields: { title: { kind: "text", required: true } } },
22
+ {
23
+ name: "Comment",
24
+ fields: { body: { kind: "text", required: true } },
25
+ annotations: { db: { tableName: "comment", indexes: [] } },
26
+ },
27
+ {
28
+ name: "Post",
29
+ fields: { title: { kind: "text", required: true } },
30
+ annotations: { db: { tableName: "post", indexes: [] } },
31
+ },
16
32
  ],
17
33
  }
18
34
 
19
35
  expect(generateClientAugmentation(astA)).toEqual(generateClientAugmentation(astB))
36
+ expect(generateClientAugmentation(astA)).toContain("post:")
37
+ expect(generateClientAugmentation(astA)).toContain("comment:")
38
+ })
39
+
40
+ it("resolves tableName from AST v2 annotations.db.tableName", () => {
41
+ const ast = {
42
+ models: [
43
+ {
44
+ name: "Profile",
45
+ fields: { id: { kind: "uuid", required: true } },
46
+ annotations: { db: { tableName: "profile", indexes: [] } },
47
+ },
48
+ ],
49
+ }
50
+ const out = generateClientAugmentation(ast)
51
+ expect(out).toContain("profile:")
52
+ expect(out).not.toContain("undefined:")
53
+ })
54
+
55
+ it("falls back to snake_case model name when tableName is missing", () => {
56
+ const ast = {
57
+ models: [{ name: "Profile", fields: { id: { kind: "uuid", required: true } } }],
58
+ }
59
+ expect(generateClientAugmentation(ast)).toContain("profile:")
20
60
  })
21
61
 
22
62
  it("marks Insert fields optional when serverGenerated or default is set", () => {
@@ -0,0 +1,25 @@
1
+ import { mkdtempSync, rmSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { describe, expect, it } from "vitest"
5
+ import { resolveRealtimeLaunch } from "../src/realtime-launch.js"
6
+ import type { SupatypeProjectConfig } from "../src/project-config.js"
7
+
8
+ describe("resolveRealtimeLaunch", () => {
9
+ it("uses overrides.realtime .js entry via node", async () => {
10
+ const dir = mkdtempSync(join(tmpdir(), "supatype-rt-launch-"))
11
+ try {
12
+ const entry = join(dir, "fake-realtime.js")
13
+ const { writeFileSync } = await import("node:fs")
14
+ writeFileSync(entry, "export {}\n")
15
+ const config = {
16
+ overrides: { realtime: entry },
17
+ } as SupatypeProjectConfig
18
+ const spec = await resolveRealtimeLaunch(config, dir)
19
+ expect(spec.bin).toBe(process.execPath)
20
+ expect(spec.args).toEqual([entry])
21
+ } finally {
22
+ rmSync(dir, { recursive: true, force: true })
23
+ }
24
+ })
25
+ })
@@ -33,6 +33,7 @@ describe("requiredHostComponents", () => {
33
33
  "engine",
34
34
  "server",
35
35
  "postgres",
36
+ "realtime",
36
37
  ])
37
38
  rmSync(dir, { recursive: true, force: true })
38
39
  })
@@ -44,6 +45,7 @@ describe("requiredHostComponents", () => {
44
45
  "engine",
45
46
  "server",
46
47
  "postgres",
48
+ "realtime",
47
49
  "deno",
48
50
  ])
49
51
  rmSync(dir, { recursive: true, force: true })
@@ -0,0 +1,29 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { describe, expect, it } from "vitest"
5
+ import { patchRouteManifest } from "../src/route-manifest.js"
6
+
7
+ describe("patchRouteManifest", () => {
8
+ it("merges realtime fields into existing manifest", () => {
9
+ const dir = mkdtempSync(join(tmpdir(), "supatype-manifest-"))
10
+ const path = join(dir, "manifest.json")
11
+ try {
12
+ patchRouteManifest(path, { realtime_enabled: true, realtime_url: "http://127.0.0.1:4000" })
13
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>
14
+ expect(parsed).toMatchObject({
15
+ realtime_enabled: true,
16
+ realtime_url: "http://127.0.0.1:4000",
17
+ })
18
+ patchRouteManifest(path, { schema: "public" })
19
+ const again = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>
20
+ expect(again).toMatchObject({
21
+ schema: "public",
22
+ realtime_enabled: true,
23
+ realtime_url: "http://127.0.0.1:4000",
24
+ })
25
+ } finally {
26
+ rmSync(dir, { recursive: true, force: true })
27
+ }
28
+ })
29
+ })