@supatype/cli 0.1.4 → 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 +98 -96
  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 +125 -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 +156 -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 +52 -0
  85. package/tsconfig.tsbuildinfo +1 -1
@@ -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
  )
@@ -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")