@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +101 -99
- 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/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/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 +14 -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 +120 -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 +169 -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/binary-cache.ts +29 -11
- package/src/cli-standalone.ts +62 -0
- package/src/commands/dev.ts +38 -0
- package/src/commands/init.ts +3 -1
- package/src/commands/keys.ts +14 -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 +144 -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 +213 -13
- 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 +153 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -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
|
|
|
@@ -265,6 +266,58 @@ function getPropertyName(name: ts.PropertyName): string | null {
|
|
|
265
266
|
return null
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Known @supatype/types intersection mixins and the field source text they contribute.
|
|
271
|
+
* Used when a mixin type can't be resolved from the local alias registry
|
|
272
|
+
* (it comes from the external @supatype/types package, not a local file).
|
|
273
|
+
*/
|
|
274
|
+
const KNOWN_MIXIN_SOURCES: Record<string, string> = {
|
|
275
|
+
Timestamps: "{ created_at: ServerDefault<Date>; updated_at: ServerDefault<Date> }",
|
|
276
|
+
SoftDelete: "{ deleted_at: Optional<Date> }",
|
|
277
|
+
Publishable: "{ published_at: Optional<Date> }",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function synthesizeTypeLiteralMembers(source: string): ts.TypeElement[] {
|
|
281
|
+
const synth = ts.createSourceFile(
|
|
282
|
+
"__synth__.ts",
|
|
283
|
+
`type __T__ = ${source}`,
|
|
284
|
+
ts.ScriptTarget.Latest,
|
|
285
|
+
true,
|
|
286
|
+
ts.ScriptKind.TS,
|
|
287
|
+
)
|
|
288
|
+
const decl = synth.statements[0]
|
|
289
|
+
if (!decl || !ts.isTypeAliasDeclaration(decl) || !ts.isTypeLiteralNode(decl.type)) return []
|
|
290
|
+
return [...decl.type.members]
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function mergeIntersectionParts(
|
|
294
|
+
parts: readonly ts.TypeNode[],
|
|
295
|
+
sourceFile: ts.SourceFile,
|
|
296
|
+
resolveCtx: ResolveContext,
|
|
297
|
+
depth: number,
|
|
298
|
+
): ts.TypeLiteralNode | null {
|
|
299
|
+
const allMembers: ts.TypeElement[] = []
|
|
300
|
+
for (const part of parts) {
|
|
301
|
+
const resolved = unwrapModelFields(part, sourceFile, resolveCtx, depth + 1)
|
|
302
|
+
if (resolved) {
|
|
303
|
+
allMembers.push(...resolved.members)
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
// Fall back to known @supatype/types intersection mixins (Timestamps, SoftDelete, Publishable)
|
|
307
|
+
if (ts.isTypeReferenceNode(part) && ts.isIdentifier(part.typeName)) {
|
|
308
|
+
const typeName = applyImportRename(part.typeName.text, sourceFile, resolveCtx.renameMap)
|
|
309
|
+
const mixinSource = KNOWN_MIXIN_SOURCES[typeName]
|
|
310
|
+
if (mixinSource) {
|
|
311
|
+
allMembers.push(...synthesizeTypeLiteralMembers(mixinSource))
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Unresolvable parts are skipped — the model still extracts with whatever fields were found
|
|
316
|
+
}
|
|
317
|
+
if (allMembers.length === 0) return null
|
|
318
|
+
return ts.factory.createTypeLiteralNode(allMembers)
|
|
319
|
+
}
|
|
320
|
+
|
|
268
321
|
function unwrapModelFields(
|
|
269
322
|
typeNode: ts.TypeNode,
|
|
270
323
|
sourceFile: ts.SourceFile,
|
|
@@ -274,6 +327,11 @@ function unwrapModelFields(
|
|
|
274
327
|
if (depth > 16) return null
|
|
275
328
|
if (ts.isTypeLiteralNode(typeNode)) return typeNode
|
|
276
329
|
|
|
330
|
+
// Handle intersection types: `{ …fields } & Timestamps`, `{ …fields } & SoftDelete`, etc.
|
|
331
|
+
if (ts.isIntersectionTypeNode(typeNode)) {
|
|
332
|
+
return mergeIntersectionParts(typeNode.types, sourceFile, resolveCtx, depth)
|
|
333
|
+
}
|
|
334
|
+
|
|
277
335
|
if (needsChecker(typeNode)) {
|
|
278
336
|
const resolved = resolveTypeNode(typeNode, sourceFile, resolveCtx)
|
|
279
337
|
if (ts.isTypeLiteralNode(resolved)) return resolved
|
|
@@ -353,6 +411,7 @@ function parseFieldType(
|
|
|
353
411
|
fieldDefault: undefined as string | number | boolean | null | undefined,
|
|
354
412
|
localized: false,
|
|
355
413
|
notLocalized: false,
|
|
414
|
+
checkConstraint: undefined as string | undefined,
|
|
356
415
|
}
|
|
357
416
|
|
|
358
417
|
const resolving = new Set<string>()
|
|
@@ -424,11 +483,46 @@ function parseFieldType(
|
|
|
424
483
|
current = valueArg ?? current
|
|
425
484
|
continue
|
|
426
485
|
}
|
|
427
|
-
case "MaxLength":
|
|
428
|
-
|
|
429
|
-
|
|
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
|
+
}
|
|
430
494
|
current = current.typeArguments?.[0] ?? current
|
|
431
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
|
+
}
|
|
523
|
+
current = current.typeArguments?.[0] ?? current
|
|
524
|
+
continue
|
|
525
|
+
}
|
|
432
526
|
case "Localized":
|
|
433
527
|
flags.localized = true
|
|
434
528
|
current = current.typeArguments?.[0] ?? current
|
|
@@ -437,36 +531,68 @@ function parseFieldType(
|
|
|
437
531
|
flags.notLocalized = true
|
|
438
532
|
current = current.typeArguments?.[0] ?? current
|
|
439
533
|
continue
|
|
440
|
-
case "RelatedTo":
|
|
534
|
+
case "RelatedTo": {
|
|
441
535
|
flags.relationCardinality = "one"
|
|
442
536
|
flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
|
|
537
|
+
const relOpts = parseRelationOptions(current.typeArguments?.[1], sourceFile)
|
|
443
538
|
// `target` must match `ModelAst.name` to satisfy validator resolution.
|
|
444
539
|
// FK column follows the field name (two relations to the same model need distinct columns).
|
|
445
540
|
return emitField({
|
|
446
541
|
kind: "relation",
|
|
447
|
-
kernel: {
|
|
542
|
+
kernel: {
|
|
543
|
+
cardinality: "belongsTo",
|
|
544
|
+
target: flags.relationTarget!,
|
|
545
|
+
...relationOptionsKernel(relOpts),
|
|
546
|
+
},
|
|
448
547
|
db: { foreignKey: relationForeignKeyFromField(fieldName) },
|
|
449
548
|
platform: flags.editorReadOnly ? { readOnly: true } : {},
|
|
450
549
|
})
|
|
451
|
-
|
|
550
|
+
}
|
|
551
|
+
case "HasOne": {
|
|
452
552
|
flags.relationCardinality = "one"
|
|
453
|
-
flags.relationTarget = current.typeArguments?.[0]
|
|
553
|
+
flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
|
|
554
|
+
const relOpts = parseRelationOptions(current.typeArguments?.[1], sourceFile)
|
|
454
555
|
return emitField({
|
|
455
556
|
kind: "relation",
|
|
456
|
-
kernel: {
|
|
557
|
+
kernel: {
|
|
558
|
+
cardinality: "hasOne",
|
|
559
|
+
target: flags.relationTarget,
|
|
560
|
+
...relationOptionsKernel(relOpts),
|
|
561
|
+
},
|
|
457
562
|
db: {},
|
|
458
563
|
platform: flags.editorReadOnly ? { readOnly: true } : {},
|
|
459
564
|
})
|
|
460
|
-
|
|
461
|
-
case "
|
|
565
|
+
}
|
|
566
|
+
case "HasMany": {
|
|
462
567
|
flags.relationCardinality = "many"
|
|
463
|
-
flags.relationTarget = current.typeArguments?.[0]
|
|
568
|
+
flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
|
|
569
|
+
const relOpts = parseRelationOptions(current.typeArguments?.[1], sourceFile)
|
|
464
570
|
return emitField({
|
|
465
571
|
kind: "relation",
|
|
466
|
-
kernel: {
|
|
572
|
+
kernel: {
|
|
573
|
+
cardinality: "hasMany",
|
|
574
|
+
target: flags.relationTarget,
|
|
575
|
+
...relationOptionsKernel(relOpts),
|
|
576
|
+
},
|
|
467
577
|
db: {},
|
|
468
578
|
platform: flags.editorReadOnly ? { readOnly: true } : {},
|
|
469
579
|
})
|
|
580
|
+
}
|
|
581
|
+
case "ManyToMany": {
|
|
582
|
+
flags.relationCardinality = "many"
|
|
583
|
+
flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
|
|
584
|
+
const relOpts = parseRelationOptions(current.typeArguments?.[1], sourceFile)
|
|
585
|
+
return emitField({
|
|
586
|
+
kind: "relation",
|
|
587
|
+
kernel: {
|
|
588
|
+
cardinality: "manyToMany",
|
|
589
|
+
target: flags.relationTarget,
|
|
590
|
+
...relationOptionsKernel(relOpts),
|
|
591
|
+
},
|
|
592
|
+
db: {},
|
|
593
|
+
platform: flags.editorReadOnly ? { readOnly: true } : {},
|
|
594
|
+
})
|
|
595
|
+
}
|
|
470
596
|
default: {
|
|
471
597
|
const resolved = tryResolveTypeReference(current, sourceFile, resolveCtx, { fieldName, resolving })
|
|
472
598
|
if (resolved) {
|
|
@@ -585,6 +711,13 @@ function parseFieldType(
|
|
|
585
711
|
parsed = { ...parsed, kernel }
|
|
586
712
|
}
|
|
587
713
|
|
|
714
|
+
if (flags.checkConstraint !== undefined) {
|
|
715
|
+
parsed = {
|
|
716
|
+
...parsed,
|
|
717
|
+
kernel: { ...parsed.kernel, check: flags.checkConstraint },
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
588
721
|
return emitField(finalizeParsedField(parsed, flags, context))
|
|
589
722
|
}
|
|
590
723
|
|
|
@@ -1477,7 +1610,7 @@ function parseModelIndexes(
|
|
|
1477
1610
|
const indexes: unknown[] = []
|
|
1478
1611
|
for (const element of indexesProp.type.elements) {
|
|
1479
1612
|
if (!ts.isTypeLiteralNode(element)) continue
|
|
1480
|
-
const indexDef: Record<string, unknown> = { using: "btree" }
|
|
1613
|
+
const indexDef: Record<string, unknown> = { using: "btree", unique: false }
|
|
1481
1614
|
for (const member of element.members) {
|
|
1482
1615
|
if (!ts.isPropertySignature(member) || !member.type) continue
|
|
1483
1616
|
const key = getPropertyName(member.name)
|
|
@@ -1571,6 +1704,73 @@ function parseAccessRule(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): Reco
|
|
|
1571
1704
|
}
|
|
1572
1705
|
}
|
|
1573
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
|
+
|
|
1574
1774
|
function relationTargetFromTypeArg(typeArg: ts.TypeNode | undefined, sourceFile: ts.SourceFile): string {
|
|
1575
1775
|
if (!typeArg) return "unknown"
|
|
1576
1776
|
const raw = typeArg.getText(sourceFile).replace(/\s/g, "")
|
|
@@ -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
|
+
})
|