@supatype/cli 0.1.3 → 0.1.5

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 (85) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +101 -99
  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/binary-cache.d.ts +7 -5
  9. package/dist/binary-cache.d.ts.map +1 -1
  10. package/dist/binary-cache.js +28 -10
  11. package/dist/binary-cache.js.map +1 -1
  12. package/dist/cli-standalone.d.ts +10 -0
  13. package/dist/cli-standalone.d.ts.map +1 -0
  14. package/dist/cli-standalone.js +56 -0
  15. package/dist/cli-standalone.js.map +1 -0
  16. package/dist/commands/dev.d.ts.map +1 -1
  17. package/dist/commands/dev.js +34 -0
  18. package/dist/commands/dev.js.map +1 -1
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +3 -1
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/keys.d.ts.map +1 -1
  23. package/dist/commands/keys.js +14 -0
  24. package/dist/commands/keys.js.map +1 -1
  25. package/dist/commands/self-update.d.ts +1 -1
  26. package/dist/commands/self-update.d.ts.map +1 -1
  27. package/dist/commands/self-update.js +33 -9
  28. package/dist/commands/self-update.js.map +1 -1
  29. package/dist/commands/update.d.ts.map +1 -1
  30. package/dist/commands/update.js +9 -1
  31. package/dist/commands/update.js.map +1 -1
  32. package/dist/components.d.ts +2 -2
  33. package/dist/components.d.ts.map +1 -1
  34. package/dist/components.js +2 -2
  35. package/dist/components.js.map +1 -1
  36. package/dist/dev-compose.d.ts.map +1 -1
  37. package/dist/dev-compose.js +120 -12
  38. package/dist/dev-compose.js.map +1 -1
  39. package/dist/project-config.d.ts +2 -0
  40. package/dist/project-config.d.ts.map +1 -1
  41. package/dist/project-config.js.map +1 -1
  42. package/dist/realtime-launch.d.ts +11 -0
  43. package/dist/realtime-launch.d.ts.map +1 -0
  44. package/dist/realtime-launch.js +39 -0
  45. package/dist/realtime-launch.js.map +1 -0
  46. package/dist/required-host-components.js +1 -1
  47. package/dist/required-host-components.js.map +1 -1
  48. package/dist/route-manifest.d.ts +3 -0
  49. package/dist/route-manifest.d.ts.map +1 -0
  50. package/dist/route-manifest.js +15 -0
  51. package/dist/route-manifest.js.map +1 -0
  52. package/dist/schema-push-lock.d.ts +17 -0
  53. package/dist/schema-push-lock.d.ts.map +1 -0
  54. package/dist/schema-push-lock.js +106 -0
  55. package/dist/schema-push-lock.js.map +1 -0
  56. package/dist/self-host-compose.d.ts.map +1 -1
  57. package/dist/self-host-compose.js +18 -0
  58. package/dist/self-host-compose.js.map +1 -1
  59. package/dist/type-extractor.d.ts.map +1 -1
  60. package/dist/type-extractor.js +169 -13
  61. package/dist/type-extractor.js.map +1 -1
  62. package/package.json +1 -1
  63. package/scripts/build-release-bundle.mjs +27 -0
  64. package/src/app-vite-env.ts +27 -0
  65. package/src/binary-cache.ts +29 -11
  66. package/src/cli-standalone.ts +62 -0
  67. package/src/commands/dev.ts +38 -0
  68. package/src/commands/init.ts +3 -1
  69. package/src/commands/keys.ts +14 -0
  70. package/src/commands/self-update.ts +34 -13
  71. package/src/commands/update.ts +9 -1
  72. package/src/components.ts +2 -2
  73. package/src/dev-compose.ts +144 -14
  74. package/src/project-config.ts +2 -0
  75. package/src/realtime-launch.ts +52 -0
  76. package/src/required-host-components.ts +1 -1
  77. package/src/route-manifest.ts +17 -0
  78. package/src/schema-push-lock.ts +131 -0
  79. package/src/self-host-compose.ts +18 -0
  80. package/src/type-extractor.ts +213 -13
  81. package/tests/realtime-launch.test.ts +25 -0
  82. package/tests/required-host-components.test.ts +2 -0
  83. package/tests/route-manifest.test.ts +29 -0
  84. package/tests/type-extractor.test.ts +153 -0
  85. package/tsconfig.tsbuildinfo +1 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Binary cache — manages supatype component binaries.
3
3
  *
4
- * Components: engine, server, postgres, deno.
4
+ * Components: engine, server, postgres, deno, realtime.
5
5
  * Cache root: ~/.supatype/cache/{component}/{version}/
6
6
  * Override path: config.overrides?.{component} (local build path).
7
7
  *
@@ -85,6 +85,7 @@ export function describeActiveOverrides(config: SupatypeProjectConfig): string[]
85
85
  add("server", o.server)
86
86
  add("postgres_dir", o.postgres_dir)
87
87
  add("deno", o.deno)
88
+ add("realtime", o.realtime)
88
89
  add("studio", o.studio)
89
90
  add("postgrest", o.postgrest)
90
91
  return lines
@@ -151,6 +152,7 @@ const CDN_PATHS: Record<Component, (version: string, platform: PlatformId) => st
151
152
  server: (v, p) => `/server/v${v}/supatype-server-${p.os}-${p.arch}${p.os === "windows" ? ".exe" : ""}`,
152
153
  postgres: (v, p) => `/postgres/v${v}/supatype-pg-${postgresArchiveTag(v)}-${p.os}-${p.arch}${p.os === "windows" ? ".zip" : ".tar.gz"}`,
153
154
  deno: (v, p) => `/deno/v${v}/deno-${p.os}-${p.arch}${p.os === "windows" ? ".exe" : ""}`,
155
+ realtime: (v, p) => `/realtime/v${v}/supatype-realtime-${p.os}-${p.arch}${p.os === "windows" ? ".exe" : ""}`,
154
156
  }
155
157
 
156
158
  // Checksums file path (one per version directory, covers all platform binaries).
@@ -190,6 +192,7 @@ function binaryName(component: Component, version: string, platform: PlatformId)
190
192
  case "server": return `supatype-server-${platform.os}-${platform.arch}${win ? ".exe" : ""}`
191
193
  case "postgres": return `supatype-pg-${postgresArchiveTag(version)}-${platform.os}-${platform.arch}${win ? ".zip" : ".tar.gz"}`
192
194
  case "deno": return `deno-${platform.os}-${platform.arch}${win ? ".exe" : ""}`
195
+ case "realtime": return `supatype-realtime-${platform.os}-${platform.arch}${win ? ".exe" : ""}`
193
196
  }
194
197
  }
195
198
 
@@ -630,7 +633,7 @@ async function streamToFileWithProgress(url: string, destPath: string): Promise<
630
633
  // SHA256 verification
631
634
  // ---------------------------------------------------------------------------
632
635
 
633
- const EXECUTABLE_COMPONENTS = new Set<Component>(["engine", "server", "deno"])
636
+ const EXECUTABLE_COMPONENTS = new Set<Component>(["engine", "server", "deno", "realtime"])
634
637
 
635
638
  /** True when a cached file matches expected format for the current platform. */
636
639
  function cachedArtifactLooksValid(component: Component, filePath: string): boolean {
@@ -655,7 +658,7 @@ export function validateArtifactFormat(
655
658
 
656
659
  /**
657
660
  * Per-component CDN artifact shapes:
658
- * engine, server, deno — native executable (ELF / Mach-O / PE)
661
+ * engine, server, deno, realtime — native executable (ELF / Mach-O / PE)
659
662
  * postgres (unix) — .tar.gz (gzip)
660
663
  * postgres (windows) — .zip
661
664
  */
@@ -867,12 +870,22 @@ export async function fetchLatestVersion(component: Component): Promise<string>
867
870
  return data.version.trim()
868
871
  }
869
872
 
870
- /** Fetch the latest version for all components concurrently. */
871
- export async function fetchAllLatestVersions(): Promise<Record<Component, string>> {
873
+ /** Fetch the latest version for all components concurrently. Missing CDN entries are omitted. */
874
+ export async function fetchAllLatestVersions(): Promise<Partial<Record<Component, string>>> {
872
875
  const results = await Promise.all(
873
- BINARY_COMPONENTS.map(async (c) => [c, await fetchLatestVersion(c)] as const),
876
+ BINARY_COMPONENTS.map(async (c) => {
877
+ try {
878
+ return [c, await fetchLatestVersion(c)] as const
879
+ } catch {
880
+ return [c, undefined] as const
881
+ }
882
+ }),
874
883
  )
875
- return Object.fromEntries(results) as Record<Component, string>
884
+ const out: Partial<Record<Component, string>> = {}
885
+ for (const [c, v] of results) {
886
+ if (v) out[c] = v
887
+ }
888
+ return out
876
889
  }
877
890
 
878
891
  // ---------------------------------------------------------------------------
@@ -885,8 +898,10 @@ export async function fetchAllLatestVersions(): Promise<Record<Component, string
885
898
  * Fails gracefully when graceful=true (suitable for postinstall).
886
899
  */
887
900
  /**
888
- * Verify all cached binaries for the current platform (used by integration CI).
889
- * Throws if any cached component is missing or fails format checks.
901
+ * Verify cached binaries for the current platform (used by integration CI).
902
+ * Only checks components present in `versions` unpublished CDN components
903
+ * (no latest.json yet) are skipped with a log so CI can land before first release.
904
+ * Throws if a pinned component is missing from cache or fails format checks.
890
905
  */
891
906
  export function verifyCachedBinaries(versions: Partial<ComponentVersions> | undefined): void {
892
907
  if (!versions) {
@@ -896,7 +911,10 @@ export function verifyCachedBinaries(versions: Partial<ComponentVersions> | unde
896
911
  for (const component of BINARY_COMPONENTS) {
897
912
  const version = versions[component]
898
913
  if (typeof version !== "string" || version.trim() === "") {
899
- throw new Error(`[supatype] versions.${component} must be set`)
914
+ console.log(
915
+ `[supatype] skipping verify for ${component} (no version — CDN latest.json not published yet)`,
916
+ )
917
+ continue
900
918
  }
901
919
  const destPath = join(cachePath(component, version), binaryName(component, version, platform))
902
920
  if (!cachedArtifactLooksValid(component, destPath)) {
@@ -918,7 +936,7 @@ export async function downloadAll(
918
936
 
919
937
  for (const component of components) {
920
938
  const version = versions?.[component] ?? latest[component]
921
- if (version === VERSION_PIN_LOCAL) continue
939
+ if (!version || version === VERSION_PIN_LOCAL) continue
922
940
  try {
923
941
  await download(component, version, platform)
924
942
  } catch (err) {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Standalone CLI binary CDN download (self-update + curl|sh installers).
3
+ */
4
+
5
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import { join } from "node:path"
8
+ import { currentPlatform, type PlatformId } from "./binary-cache.js"
9
+
10
+ const CDN_BASE = "https://releases.supatype.com"
11
+
12
+ export function cliCdnPath(version: string, platform: PlatformId): string {
13
+ const win = platform.os === "windows"
14
+ return `/cli/v${version}/supatype-cli-${platform.os}-${platform.arch}${win ? ".exe" : ""}`
15
+ }
16
+
17
+ export function cliCachePath(version: string, platform: PlatformId = currentPlatform()): string {
18
+ const win = platform.os === "windows"
19
+ const name = `supatype-cli-${platform.os}-${platform.arch}${win ? ".exe" : ""}`
20
+ return join(homedir(), ".supatype", "cache", "cli", version, name)
21
+ }
22
+
23
+ export async function fetchStandaloneCliLatestVersion(): Promise<string> {
24
+ const resp = await fetch(`${CDN_BASE}/cli/latest.json`)
25
+ if (!resp.ok) {
26
+ throw new Error(`Failed to fetch CLI latest.json: HTTP ${resp.status}`)
27
+ }
28
+ const data = await resp.json() as { version?: unknown }
29
+ if (typeof data.version !== "string" || data.version.trim() === "") {
30
+ throw new Error("Invalid cli/latest.json: missing version")
31
+ }
32
+ return data.version.trim()
33
+ }
34
+
35
+ /** Download the standalone CLI binary for the current platform. */
36
+ export async function downloadStandaloneCli(version?: string): Promise<string> {
37
+ const platform = currentPlatform()
38
+ const resolved = version ?? await fetchStandaloneCliLatestVersion()
39
+ const dest = cliCachePath(resolved, platform)
40
+ if (existsSync(dest)) return dest
41
+
42
+ const url = `${CDN_BASE}${cliCdnPath(resolved, platform)}`
43
+ const resp = await fetch(url)
44
+ if (!resp.ok) {
45
+ throw new Error(`Failed to download CLI from ${url}: HTTP ${resp.status}`)
46
+ }
47
+ const buf = Buffer.from(await resp.arrayBuffer())
48
+ if (buf.length < 64) {
49
+ throw new Error("Downloaded CLI artifact is too small")
50
+ }
51
+ const head = buf.subarray(0, 5).toString("utf8").toLowerCase()
52
+ if (head.startsWith("<!doc") || head.startsWith("{")) {
53
+ throw new Error("Downloaded CLI artifact looks like an error page, not a binary")
54
+ }
55
+
56
+ mkdirSync(join(dest, ".."), { recursive: true })
57
+ writeFileSync(dest, buf)
58
+ if (platform.os !== "windows") {
59
+ chmodSync(dest, 0o755)
60
+ }
61
+ return dest
62
+ }
@@ -44,6 +44,9 @@ import { publishDevReady } from "../dev-ready-panel.js"
44
44
  import { probeDockerDaemon, reportDockerUnavailable } from "../docker-runtime.js"
45
45
  import { fatalError } from "../ui/fatal.js"
46
46
  import { registerDevShutdown } from "../dev-shutdown.js"
47
+ import { patchRouteManifest } from "../route-manifest.js"
48
+ import { resolveRealtimeLaunch } from "../realtime-launch.js"
49
+ import { writeAppViteEnv } from "../app-vite-env.js"
47
50
  import { ensureValkeySidecar, stopValkeySidecar } from "../valkey-sidecar.js"
48
51
  import {
49
52
  initdb,
@@ -72,6 +75,7 @@ function gotrueSMTPFromEmailConfig(email: SupatypeProjectConfig["email"] | undef
72
75
  }
73
76
 
74
77
  const NATIVE_PG_PORT = 5432
78
+ const REALTIME_PORT = "4000"
75
79
 
76
80
  function portCheckCommand(port: number): string {
77
81
  return process.platform === "win32"
@@ -316,6 +320,38 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticate
316
320
  const anonKey = signJwt({ ...jwtBase, role: "anon" }, LOCAL_JWT_SECRET)
317
321
  const serviceRoleKey = signJwt({ ...jwtBase, role: "service_role" }, LOCAL_JWT_SECRET)
318
322
 
323
+ const realtimeUrl = `http://127.0.0.1:${REALTIME_PORT}`
324
+ patchRouteManifest(manifestPath, {
325
+ realtime_enabled: true,
326
+ realtime_url: realtimeUrl,
327
+ })
328
+
329
+ writeAppViteEnv(cwd, config, `http://localhost:${serverPort}`, anonKey)
330
+
331
+ let realtimeProc: ProcessManager | null = null
332
+ try {
333
+ const launch = await resolveRealtimeLaunch(config, cwd)
334
+ realtimeProc = new ProcessManager(launch.bin, launch.args, {
335
+ label: "realtime",
336
+ pidDir,
337
+ colour: "\x1b[35m",
338
+ env: {
339
+ DATABASE_URL: dbURL,
340
+ JWT_SECRET: LOCAL_JWT_SECRET,
341
+ PORT: REALTIME_PORT,
342
+ SLOT_NAME: "supatype_realtime",
343
+ PUBLICATION_NAME: "supatype_realtime_pub",
344
+ },
345
+ })
346
+ realtimeProc.start()
347
+ console.log(`[supatype] Realtime service: ${realtimeUrl}`)
348
+ } catch (err) {
349
+ console.warn(
350
+ `[supatype] ⚠ Realtime unavailable — WebSocket subscriptions will not work.\n` +
351
+ ` ${(err as Error).message}`,
352
+ )
353
+ }
354
+
319
355
 
320
356
  const emailProvider = config.email?.provider ?? "console"
321
357
  const gotrueMailerProvider =
@@ -344,6 +380,7 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticate
344
380
  ? { SUPATYPE_DENO_SERVE_SCRIPT: denoServeScriptAbs }
345
381
  : {}),
346
382
  SUPATYPE_URL: `http://localhost:${serverPort}`,
383
+ SUPATYPE_REALTIME_URL: realtimeUrl,
347
384
  SUPATYPE_ANON_KEY: anonKey,
348
385
  SUPATYPE_SERVICE_ROLE_KEY: serviceRoleKey,
349
386
  PORT: serverPort,
@@ -518,6 +555,7 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticate
518
555
  console.log("[supatype] Shutting down...")
519
556
  await Promise.all([
520
557
  serverProc.stop(),
558
+ realtimeProc?.stop(),
521
559
  postgrestProc?.stop(),
522
560
  studioProc?.stop(),
523
561
  appProc?.stop(),
@@ -1201,7 +1201,9 @@ SITE_URL=http://localhost:3000`)
1201
1201
  `# Local API gateway (Kong) — unique per project so multiple stacks can run concurrently
1202
1202
  SUPATYPE_KONG_PORT=${opts.kongPort}
1203
1203
  PUBLIC_SUPATYPE_URL=${apiUrl}
1204
- API_EXTERNAL_URL=${apiUrl}`,
1204
+ API_EXTERNAL_URL=${apiUrl}
1205
+ VITE_SUPATYPE_URL=${apiUrl}
1206
+ VITE_SUPATYPE_ANON_KEY=`,
1205
1207
  )
1206
1208
  }
1207
1209
 
@@ -70,6 +70,12 @@ export function generateAndWriteKeys(
70
70
  let content = readFileSync(envPath, "utf8")
71
71
  content = upsertEnvVar(content, "ANON_KEY", anonKey)
72
72
  content = upsertEnvVar(content, "SERVICE_ROLE_KEY", serviceKey)
73
+ content = upsertEnvVar(content, "VITE_SUPATYPE_ANON_KEY", anonKey)
74
+ content = upsertEnvVar(content, "PUBLIC_SUPATYPE_ANON_KEY", anonKey)
75
+ const apiUrl = readEnvVar(content, "PUBLIC_SUPATYPE_URL") ?? readEnvVar(content, "API_EXTERNAL_URL")
76
+ if (apiUrl) {
77
+ content = upsertEnvVar(content, "VITE_SUPATYPE_URL", apiUrl)
78
+ }
73
79
  writeFileSync(envPath, content, "utf8")
74
80
  }
75
81
 
@@ -83,6 +89,14 @@ function upsertEnvVar(content: string, key: string, value: string): string {
83
89
  return `${content}${sep}${key}=${value}\n`
84
90
  }
85
91
 
92
+ function readEnvVar(content: string, key: string): string | undefined {
93
+ const re = new RegExp(`^${key}=(.*)$`, "m")
94
+ const match = re.exec(content)
95
+ if (!match?.[1]) return undefined
96
+ const value = match[1].trim()
97
+ return value.length > 0 ? value : undefined
98
+ }
99
+
86
100
  export function resolveSecret(dir: string = process.cwd()): string | undefined {
87
101
  // 1. Check environment variable
88
102
  const fromEnv = process.env["JWT_SECRET"]
@@ -2,12 +2,14 @@
2
2
  * supatype self-update — Phase 10.6C8.
3
3
  *
4
4
  * npm-installed CLI: instruct to use `npm update -g`.
5
- * Standalone / future curl|sh installs: CDN-published CLI binary replacement is not wired yet.
5
+ * Standalone curl|sh installs: replace binary from releases.supatype.com/cli/.
6
6
  */
7
7
 
8
8
  import type { Command } from "commander"
9
+ import { copyFileSync, renameSync } from "node:fs"
9
10
  import { sep } from "node:path"
10
- import { error, plain } from "../ui/messages.js"
11
+ import { downloadStandaloneCli, fetchStandaloneCliLatestVersion } from "../cli-standalone.js"
12
+ import { error, info, plain } from "../ui/messages.js"
11
13
 
12
14
  function looksLikeNpmOrWorkspaceCLI(): boolean {
13
15
  const main = process.argv[1] ?? ""
@@ -23,24 +25,43 @@ function looksLikeNpmOrWorkspaceCLI(): boolean {
23
25
  export function registerSelfUpdate(program: Command): void {
24
26
  program
25
27
  .command("self-update")
26
- .description(
27
- "Update the supatype CLI (npm: use npm update; standalone binary swap from CDN is not available yet)",
28
- )
29
- .action(() => {
28
+ .description("Update the supatype CLI (npm or standalone CDN binary)")
29
+ .action(async () => {
30
30
  if (looksLikeNpmOrWorkspaceCLI()) {
31
31
  plain(
32
32
  "This CLI was installed via npm (or runs from the package workspace).\n" +
33
33
  "Update with:\n\n npm update -g @supatype/cli\n\n" +
34
- "To bump engine/server/postgres/deno pinned in supatype.config.ts, use:\n\n supatype update\n",
34
+ "To bump engine/server/postgres/deno/realtime pinned in supatype.config.ts, use:\n\n supatype update\n",
35
35
  )
36
36
  return
37
37
  }
38
38
 
39
- error(
40
- "Standalone CLI self-update (replace binary from CDN) is not published yet.\n\n" +
41
- "Install or upgrade via npm:\n npm install -g @supatype/cli\n\n" +
42
- "Component binaries (engine, server, postgres, deno):\n supatype update\n",
43
- )
44
- process.exit(1)
39
+ const currentPath = process.argv[1]
40
+ if (!currentPath) {
41
+ error("Could not determine current CLI path.")
42
+ process.exit(1)
43
+ }
44
+
45
+ try {
46
+ const latest = await fetchStandaloneCliLatestVersion()
47
+ info(`Downloading supatype CLI v${latest}...`)
48
+ const downloaded = await downloadStandaloneCli(latest)
49
+ const backup = `${currentPath}.bak`
50
+ copyFileSync(currentPath, backup)
51
+ try {
52
+ renameSync(downloaded, currentPath)
53
+ } catch (err) {
54
+ copyFileSync(downloaded, currentPath)
55
+ }
56
+ info(`Updated to v${latest}.`)
57
+ plain("Run `supatype --version` to verify.")
58
+ } catch (err) {
59
+ error(
60
+ `Standalone CLI self-update failed: ${(err as Error).message}\n\n` +
61
+ "Install or upgrade via npm:\n npm install -g @supatype/cli\n\n" +
62
+ "Component binaries (engine, server, postgres, deno, realtime):\n supatype update\n",
63
+ )
64
+ process.exit(1)
65
+ }
45
66
  })
46
67
  }
@@ -59,7 +59,7 @@ export function registerUpdate(program: Command): void {
59
59
 
60
60
  const platform = currentPlatform()
61
61
 
62
- const components: Component[] = ["engine", "server", "postgres", "deno"]
62
+ const components: Component[] = ["engine", "server", "postgres", "deno", "realtime"]
63
63
  const updates: Array<{ component: Component; from: string; to: string }> = []
64
64
 
65
65
  info("Fetching latest component versions from CDN...")
@@ -67,6 +67,14 @@ export function registerUpdate(program: Command): void {
67
67
 
68
68
  for (const component of components) {
69
69
  const latest = latestVersions[component]
70
+ if (!latest) {
71
+ if (component === "realtime") {
72
+ info(`Skipping ${component}: not published on CDN yet`)
73
+ continue
74
+ }
75
+ error(`No latest version for ${component} on CDN`)
76
+ continue
77
+ }
70
78
  const current = pinnedVersion(component, config)
71
79
  if (!current) {
72
80
  if (opts.check) {
package/src/components.ts CHANGED
@@ -1,5 +1,5 @@
1
- /** CDN-cached binaries resolved by the CLI (`engine`, `server`, `postgres`, `deno`). */
2
- export const BINARY_COMPONENTS = ["engine", "server", "postgres", "deno"] as const
1
+ /** CDN-cached binaries resolved by the CLI (`engine`, `server`, `postgres`, `deno`, `realtime`). */
2
+ export const BINARY_COMPONENTS = ["engine", "server", "postgres", "deno", "realtime"] as const
3
3
 
4
4
  export type Component = (typeof BINARY_COMPONENTS)[number]
5
5
 
@@ -8,6 +8,7 @@ import { dirname, join, resolve } from "node:path"
8
8
  import { spawnSync } from "node:child_process"
9
9
  import { startProxyDevApp, resolveProxyDevScript } from "./app/proxy-dev-app.js"
10
10
  import { loadSchemaAst } from "./config.js"
11
+ import { withComposeSchemaPushLock } from "./schema-push-lock.js"
11
12
  import {
12
13
  COMPOSE_DEV_KONG_PORT,
13
14
  connectionString,
@@ -236,12 +237,69 @@ async function waitComposeHealthy(paths: SelfHostComposePaths, cwd: string, maxM
236
237
  throw new Error("Compose db service did not become healthy in time")
237
238
  }
238
239
 
240
+ /** True when the named compose service has a running container. */
241
+ function composeServiceIsRunning(
242
+ paths: SelfHostComposePaths,
243
+ cwd: string,
244
+ composeProject: string,
245
+ service: string,
246
+ ): boolean {
247
+ const envFile = join(cwd, ".env")
248
+ const args = ["compose", "-p", composeProject, "--project-directory", cwd, "-f", paths.composePath]
249
+ if (existsSync(envFile)) args.push("--env-file", envFile)
250
+ args.push("ps", "-q", "--status", "running", service)
251
+ const result = spawnSync("docker", args, { cwd, encoding: "utf8" })
252
+ return result.status === 0 && typeof result.stdout === "string" && result.stdout.trim() !== ""
253
+ }
254
+
255
+ /**
256
+ * Capture Postgres container logs before compose down destroys them.
257
+ * Writes `.supatype/ci-logs/db-*.log` and prints a tail for CI job logs.
258
+ */
259
+ function dumpComposeDbLogs(
260
+ paths: SelfHostComposePaths,
261
+ cwd: string,
262
+ composeProject: string,
263
+ reason: string,
264
+ ): void {
265
+ const logDir = join(cwd, ".supatype", "ci-logs")
266
+ mkdirSync(logDir, { recursive: true })
267
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-")
268
+ const logPath = join(logDir, `db-${stamp}.log`)
269
+
270
+ const envFile = join(cwd, ".env")
271
+ const args = ["compose", "-p", composeProject, "--project-directory", cwd, "-f", paths.composePath]
272
+ if (existsSync(envFile)) args.push("--env-file", envFile)
273
+ args.push("logs", "--no-color", "--timestamps", "--tail", "800", "db")
274
+
275
+ const result = spawnSync("docker", args, {
276
+ cwd,
277
+ encoding: "utf8",
278
+ maxBuffer: 10 * 1024 * 1024,
279
+ })
280
+ const body = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
281
+ const content = body || `(empty — docker compose logs db exit ${result.status ?? "?"})\n`
282
+ try {
283
+ writeFileSync(logPath, content)
284
+ } catch {
285
+ /* best-effort */
286
+ }
287
+
288
+ const lines = content.split("\n")
289
+ const tail = lines.slice(-200).join("\n")
290
+ console.error(`[supatype] Postgres logs after ${reason} (saved ${logPath}):`)
291
+ console.error(tail)
292
+ }
293
+
239
294
  async function waitKongReady(kongPort: number, maxSec: number): Promise<void> {
240
295
  const base = `http://localhost:${kongPort}`
241
296
  for (let i = 0; i < maxSec; i++) {
242
297
  try {
243
- const res = await fetch(`${base}/auth/v1/health`)
244
- if (res.ok) return
298
+ const [auth, realtime] = await Promise.all([
299
+ fetch(`${base}/auth/v1/health`),
300
+ fetch(`${base}/realtime/v1/health`),
301
+ ])
302
+ if (auth.ok && realtime.ok) return
245
303
  } catch {
246
304
  /* retry */
247
305
  }
@@ -405,12 +463,20 @@ async function runComposeSchemaPush(
405
463
 
406
464
  console.log("[supatype] Applying schema via compose schema-engine...")
407
465
  const sources = writeSchemaSourcePushArtifacts(cwd)
408
- let push = await runComposeEnginePush(paths, cwd, composeProject, config, sources)
409
- // Windows Docker bind mounts can lag briefly after the host write.
410
- if (push.status !== 0) {
411
- await new Promise((r) => setTimeout(r, 250))
412
- push = await runComposeEnginePush(paths, cwd, composeProject, config, sources)
466
+ const runPush = async () => {
467
+ let result = await runComposeEnginePush(paths, cwd, composeProject, config, sources)
468
+ // Windows Docker bind mounts can lag briefly after the host write.
469
+ if (result.status !== 0) {
470
+ await new Promise((r) => setTimeout(r, 250))
471
+ result = await runComposeEnginePush(paths, cwd, composeProject, config, sources)
472
+ }
473
+ return result
413
474
  }
475
+ // B: only hold the advisory lock when realtime is already decoding — first-boot
476
+ // push (db only) must not take the lock; that path crashed under lock+DDL in CI.
477
+ const push = composeServiceIsRunning(paths, cwd, composeProject, "realtime")
478
+ ? await withComposeSchemaPushLock(paths, cwd, composeProject, runPush)
479
+ : await runPush()
414
480
  if (push.status !== 0) {
415
481
  _lastFailedAst = astJson
416
482
  const detail = filterComposeNoise(push.output) || push.output
@@ -750,23 +816,71 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
750
816
  await recoverStaleDevSession(cwd)
751
817
  await handleComposeProjectRename(cwd, config.project.name, paths)
752
818
 
753
- console.log("[supatype] Bringing up Docker Compose services...")
754
- const upStatus = runDockerCompose(paths.composePath, ["up", "-d"], cwd, project, {
819
+ console.log("[supatype] Bringing up Postgres (compose db)...")
820
+ const upDbStatus = runDockerCompose(paths.composePath, ["up", "-d", "db"], cwd, project, {
755
821
  quiet: true,
756
822
  brand: devBrand,
757
823
  })
758
- if (upStatus !== 0) {
824
+ if (upDbStatus !== 0) {
759
825
  endDevSession()
760
- exitComposeFailed(upStatus, "Could not start the local Compose stack.", devBrand)
826
+ exitComposeFailed(upDbStatus, "Could not start Postgres (compose db service).", devBrand)
761
827
  }
762
828
 
763
829
  console.log("[supatype] Waiting for Postgres (compose)...")
764
830
  await waitComposeHealthy(paths, cwd, 180_000, project)
831
+ // Settle before DDL — pg_isready can pass slightly before the instance is stable.
832
+ await new Promise((r) => setTimeout(r, 3000))
765
833
 
834
+ // A: apply schema before realtime (and the rest of the stack) starts decoding WAL.
766
835
  const schemaPath = schemaPathFromProject(config, cwd)
767
- await runComposeSchemaPush(cwd, config, paths, schemaPath, project).catch((e: unknown) =>
768
- console.error("[supatype] Initial schema push failed:", (e as Error).message),
769
- )
836
+ {
837
+ const maxAttempts = 3
838
+ let lastErr: unknown
839
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
840
+ try {
841
+ await runComposeSchemaPush(cwd, config, paths, schemaPath, project)
842
+ lastErr = undefined
843
+ break
844
+ } catch (e: unknown) {
845
+ lastErr = e
846
+ console.error(
847
+ `[supatype] Initial schema push failed (attempt ${attempt}/${maxAttempts}):`,
848
+ (e as Error).message,
849
+ )
850
+ dumpComposeDbLogs(paths, cwd, project, `schema push attempt ${attempt}/${maxAttempts}`)
851
+ if (attempt < maxAttempts) {
852
+ console.log("[supatype] Resetting Postgres after failed schema push...")
853
+ runDockerCompose(paths.composePath, ["down", "-v"], cwd, project, {
854
+ quiet: true,
855
+ brand: devBrand,
856
+ })
857
+ runDockerCompose(paths.composePath, ["up", "-d", "db"], cwd, project, {
858
+ quiet: true,
859
+ brand: devBrand,
860
+ })
861
+ await waitComposeHealthy(paths, cwd, 120_000, project)
862
+ await new Promise((r) => setTimeout(r, 3000 * attempt))
863
+ }
864
+ }
865
+ }
866
+ if (lastErr) {
867
+ dumpComposeDbLogs(paths, cwd, project, "initial schema push exhausted")
868
+ endDevSession()
869
+ throw new Error(
870
+ `Initial schema push failed after ${maxAttempts} attempts: ${(lastErr as Error).message}`,
871
+ )
872
+ }
873
+ }
874
+
875
+ console.log("[supatype] Bringing up Docker Compose services...")
876
+ const upStatus = runDockerCompose(paths.composePath, ["up", "-d"], cwd, project, {
877
+ quiet: true,
878
+ brand: devBrand,
879
+ })
880
+ if (upStatus !== 0) {
881
+ endDevSession()
882
+ exitComposeFailed(upStatus, "Could not start the local Compose stack.", devBrand)
883
+ }
770
884
 
771
885
  if (localServerImage !== undefined) {
772
886
  console.log("[supatype] Recreating server with local image...")
@@ -783,6 +897,22 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
783
897
  }
784
898
  }
785
899
 
900
+ const pinnedRealtimeImage = readEnvValue(cwd, "SUPATYPE_REALTIME_IMAGE", "").trim()
901
+ if (pinnedRealtimeImage !== "") {
902
+ console.log("[supatype] Recreating realtime with pinned image...")
903
+ const rtStatus = runDockerCompose(
904
+ paths.composePath,
905
+ ["up", "-d", "--force-recreate", "--no-deps", "realtime"],
906
+ cwd,
907
+ project,
908
+ { quiet: true, brand: devBrand },
909
+ )
910
+ if (rtStatus !== 0) {
911
+ endDevSession()
912
+ exitComposeFailed(rtStatus, "Could not recreate the realtime container.", devBrand)
913
+ }
914
+ }
915
+
786
916
  console.log("[supatype] Waiting for API gateway...")
787
917
  await waitKongReady(kongPort, 120)
788
918
  console.log("[supatype] Waiting for storage API...")
@@ -116,6 +116,8 @@ export interface SupatypeProjectConfig {
116
116
  postgres_dir?: string
117
117
  /** Path to a local deno binary. */
118
118
  deno?: string
119
+ /** Path to a local supatype-realtime binary (or node entry script). */
120
+ realtime?: string
119
121
  /** Path to the @supatype/studio package directory (starts Vite dev server). */
120
122
  studio?: string
121
123
  /** Path to a local PostgREST binary. */
@@ -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
  )