@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +99 -97
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/app-vite-env.d.ts +4 -0
- package/dist/app-vite-env.d.ts.map +1 -0
- package/dist/app-vite-env.js +22 -0
- package/dist/app-vite-env.js.map +1 -0
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +26 -4
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/binary-cache.d.ts +7 -5
- package/dist/binary-cache.d.ts.map +1 -1
- package/dist/binary-cache.js +28 -10
- package/dist/binary-cache.js.map +1 -1
- package/dist/cli-standalone.d.ts +10 -0
- package/dist/cli-standalone.d.ts.map +1 -0
- package/dist/cli-standalone.js +56 -0
- package/dist/cli-standalone.js.map +1 -0
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +10 -3
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +34 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +3 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +16 -0
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/self-update.d.ts +1 -1
- package/dist/commands/self-update.d.ts.map +1 -1
- package/dist/commands/self-update.js +33 -9
- package/dist/commands/self-update.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +9 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/components.d.ts +2 -2
- package/dist/components.d.ts.map +1 -1
- package/dist/components.js +2 -2
- package/dist/components.js.map +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +122 -12
- package/dist/dev-compose.js.map +1 -1
- package/dist/project-config.d.ts +2 -0
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js.map +1 -1
- package/dist/realtime-launch.d.ts +11 -0
- package/dist/realtime-launch.d.ts.map +1 -0
- package/dist/realtime-launch.js +39 -0
- package/dist/realtime-launch.js.map +1 -0
- package/dist/required-host-components.js +1 -1
- package/dist/required-host-components.js.map +1 -1
- package/dist/route-manifest.d.ts +3 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +15 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/schema-push-lock.d.ts +17 -0
- package/dist/schema-push-lock.d.ts.map +1 -0
- package/dist/schema-push-lock.js +106 -0
- package/dist/schema-push-lock.js.map +1 -0
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +18 -0
- package/dist/self-host-compose.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +125 -13
- package/dist/type-extractor.js.map +1 -1
- package/package.json +1 -1
- package/scripts/build-release-bundle.mjs +27 -0
- package/src/app-vite-env.ts +27 -0
- package/src/augmentation-generator.ts +29 -5
- package/src/binary-cache.ts +29 -11
- package/src/cli-standalone.ts +62 -0
- package/src/commands/admin.ts +13 -3
- package/src/commands/dev.ts +38 -0
- package/src/commands/init.ts +3 -1
- package/src/commands/keys.ts +16 -0
- package/src/commands/self-update.ts +34 -13
- package/src/commands/update.ts +9 -1
- package/src/components.ts +2 -2
- package/src/dev-compose.ts +146 -14
- package/src/project-config.ts +2 -0
- package/src/realtime-launch.ts +52 -0
- package/src/required-host-components.ts +1 -1
- package/src/route-manifest.ts +17 -0
- package/src/schema-push-lock.ts +131 -0
- package/src/self-host-compose.ts +18 -0
- package/src/type-extractor.ts +156 -13
- package/tests/augmentation-generator.test.ts +44 -4
- package/tests/realtime-launch.test.ts +25 -0
- package/tests/required-host-components.test.ts +2 -0
- package/tests/route-manifest.test.ts +29 -0
- package/tests/type-extractor.test.ts +52 -0
- 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
|
+
}
|
package/src/self-host-compose.ts
CHANGED
|
@@ -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")
|
package/src/type-extractor.ts
CHANGED
|
@@ -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
|
-
|
|
486
|
-
|
|
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: {
|
|
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
|
-
|
|
550
|
+
}
|
|
551
|
+
case "HasOne": {
|
|
509
552
|
flags.relationCardinality = "one"
|
|
510
|
-
flags.relationTarget = current.typeArguments?.[0]
|
|
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: {
|
|
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
|
-
|
|
518
|
-
case "ManyToMany":
|
|
580
|
+
}
|
|
581
|
+
case "ManyToMany": {
|
|
519
582
|
flags.relationCardinality = "many"
|
|
520
|
-
flags.relationTarget = current.typeArguments?.[0]
|
|
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: {
|
|
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
|
-
{
|
|
9
|
-
|
|
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
|
-
{
|
|
15
|
-
|
|
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
|
+
})
|