@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
@@ -0,0 +1,17 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
2
+
3
+ /** Merge fields into `.supatype/manifest.json` (creates file if missing). */
4
+ export function patchRouteManifest(
5
+ manifestPath: string,
6
+ patch: Record<string, unknown>,
7
+ ): void {
8
+ let manifest: Record<string, unknown> = {}
9
+ if (existsSync(manifestPath)) {
10
+ try {
11
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>
12
+ } catch {
13
+ manifest = {}
14
+ }
15
+ }
16
+ writeFileSync(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`, "utf8")
17
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Advisory lock held while compose schema push runs so realtime can skip WAL
3
+ * decoding without stopping the realtime process (self-host safe).
4
+ *
5
+ * Must stay in sync with:
6
+ * - packages/realtime/src/schema-push-lock.ts
7
+ * - supatype-schema-engine (pg_advisory_xact_lock on the same keys)
8
+ */
9
+
10
+ import { existsSync } from "node:fs"
11
+ import { resolve } from "node:path"
12
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process"
13
+ import type { SelfHostComposePaths } from "./self-host-compose.js"
14
+ import { readEnvValue } from "./env-file.js"
15
+
16
+ export const SCHEMA_PUSH_LOCK_CLASSID = 872014
17
+ export const SCHEMA_PUSH_LOCK_OBJID = 1
18
+
19
+ function composeBaseArgs(
20
+ paths: SelfHostComposePaths,
21
+ cwd: string,
22
+ composeProject: string,
23
+ ): string[] {
24
+ const envFile = resolve(cwd, ".env")
25
+ const args = ["compose", "-p", composeProject, "--project-directory", cwd, "-f", paths.composePath]
26
+ if (existsSync(envFile)) args.push("--env-file", envFile)
27
+ return args
28
+ }
29
+
30
+ function dbExecArgs(
31
+ paths: SelfHostComposePaths,
32
+ cwd: string,
33
+ composeProject: string,
34
+ psqlArgs: string[],
35
+ ): string[] {
36
+ const pass = readEnvValue(cwd, "POSTGRES_PASSWORD", "postgres")
37
+ const user = readEnvValue(cwd, "POSTGRES_USER", "supatype_admin")
38
+ const db = readEnvValue(cwd, "POSTGRES_DB", "supatype")
39
+ return [
40
+ ...composeBaseArgs(paths, cwd, composeProject),
41
+ "exec",
42
+ "-T",
43
+ "-e",
44
+ `PGPASSWORD=${pass}`,
45
+ "db",
46
+ "psql",
47
+ "-U",
48
+ user,
49
+ "-d",
50
+ db,
51
+ "-v",
52
+ "ON_ERROR_STOP=1",
53
+ ...psqlArgs,
54
+ ]
55
+ }
56
+
57
+ function schemaPushLockIsHeld(
58
+ paths: SelfHostComposePaths,
59
+ cwd: string,
60
+ composeProject: string,
61
+ ): boolean {
62
+ const args = dbExecArgs(paths, cwd, composeProject, [
63
+ "-tAc",
64
+ `SELECT EXISTS (
65
+ SELECT 1 FROM pg_locks
66
+ WHERE locktype = 'advisory'
67
+ AND classid = ${SCHEMA_PUSH_LOCK_CLASSID}
68
+ AND objid = ${SCHEMA_PUSH_LOCK_OBJID}
69
+ AND granted
70
+ )`,
71
+ ])
72
+ const result = spawnSync("docker", args, { cwd, encoding: "utf8" })
73
+ return result.status === 0 && result.stdout.trim() === "t"
74
+ }
75
+
76
+ /**
77
+ * Hold a session advisory lock for the duration of `fn` via a background
78
+ * `psql` inside the compose db container. Killing the holder releases the lock.
79
+ */
80
+ export async function withComposeSchemaPushLock<T>(
81
+ paths: SelfHostComposePaths,
82
+ cwd: string,
83
+ composeProject: string,
84
+ fn: () => Promise<T>,
85
+ ): Promise<T> {
86
+ const holdSql =
87
+ `SELECT pg_advisory_lock(${SCHEMA_PUSH_LOCK_CLASSID}, ${SCHEMA_PUSH_LOCK_OBJID});` +
88
+ ` SELECT pg_sleep(86400);`
89
+ const args = dbExecArgs(paths, cwd, composeProject, ["-c", holdSql])
90
+
91
+ let stderrBuf = ""
92
+ const holder: ChildProcess = spawn("docker", args, {
93
+ cwd,
94
+ stdio: ["ignore", "pipe", "pipe"],
95
+ })
96
+ holder.stderr?.on("data", (chunk: Buffer) => {
97
+ stderrBuf += chunk.toString()
98
+ })
99
+
100
+ const deadline = Date.now() + 30_000
101
+ while (Date.now() < deadline) {
102
+ if (holder.exitCode !== null) {
103
+ const err = stderrBuf.trim() || "lock holder exited"
104
+ throw new Error(`[supatype] Failed to acquire schema-push advisory lock: ${err}`)
105
+ }
106
+ if (schemaPushLockIsHeld(paths, cwd, composeProject)) break
107
+ await new Promise((r) => setTimeout(r, 200))
108
+ }
109
+ if (!schemaPushLockIsHeld(paths, cwd, composeProject)) {
110
+ holder.kill("SIGTERM")
111
+ throw new Error("[supatype] Timed out waiting for schema-push advisory lock")
112
+ }
113
+
114
+ try {
115
+ return await fn()
116
+ } finally {
117
+ if (holder.exitCode === null) {
118
+ holder.kill("SIGTERM")
119
+ await new Promise<void>((resolveDone) => {
120
+ const t = setTimeout(() => {
121
+ holder.kill("SIGKILL")
122
+ resolveDone()
123
+ }, 3000)
124
+ holder.once("exit", () => {
125
+ clearTimeout(t)
126
+ resolveDone()
127
+ })
128
+ })
129
+ }
130
+ }
131
+ }
@@ -383,6 +383,20 @@ ${dbPorts} volumes:
383
383
  db:
384
384
  condition: service_healthy
385
385
 
386
+ realtime:
387
+ image: \${SUPATYPE_REALTIME_IMAGE:-supatype/realtime:latest}
388
+ expose:
389
+ - "4000"
390
+ environment:
391
+ PORT: "4000"
392
+ DATABASE_URL: "postgresql://\${POSTGRES_USER:-supatype_admin}:\${POSTGRES_PASSWORD:-postgres}@db:5432/\${POSTGRES_DB:-supatype}"
393
+ JWT_SECRET: \${JWT_SECRET:-super-secret-jwt-token-change-in-production}
394
+ SLOT_NAME: supatype_realtime
395
+ PUBLICATION_NAME: supatype_realtime_pub
396
+ depends_on:
397
+ db:
398
+ condition: service_healthy
399
+
386
400
  control-plane:
387
401
  image: \${SUPATYPE_CONTROL_PLANE_IMAGE:-supatype/control-plane:latest}
388
402
  expose:
@@ -423,6 +437,7 @@ ${serverPorts} volumes:
423
437
  SUPATYPE_SQL_DATABASE_URL: "postgresql://\${POSTGRES_USER:-supatype_admin}:\${POSTGRES_PASSWORD:-postgres}@db:5432/\${POSTGRES_DB:-supatype}"
424
438
  SUPATYPE_DENO_FUNCTIONS_DIR: /project/functions
425
439
  SUPATYPE_FUNCTIONS_WORKER_URL: http://functions-worker:8001
440
+ SUPATYPE_REALTIME_URL: http://realtime:4000
426
441
  SUPATYPE_CONTROL_PLANE_URL: http://control-plane:8080
427
442
  SUPATYPE_VALKEY_ADDR: valkey:6379
428
443
  ${appEnv}
@@ -452,6 +467,8 @@ ${devLocal ? " STUDIO_OPEN_DEV: \"1\"\n" : ""}
452
467
  condition: service_started
453
468
  functions-worker:
454
469
  condition: service_started
470
+ realtime:
471
+ condition: service_started
455
472
  control-plane:
456
473
  condition: service_started
457
474
 
@@ -502,6 +519,7 @@ function ensureComposeManifest(cwd: string): void {
502
519
  postgrest_url: "http://postgrest:3000",
503
520
  storage_url: "http://storage:5000",
504
521
  realtime_enabled: true,
522
+ realtime_url: "http://realtime:4000",
505
523
  functions_enabled: false,
506
524
  }
507
525
  writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8")
@@ -22,6 +22,7 @@ import {
22
22
  type ExtractedSchemaAstV2,
23
23
  type ExtractedStorageBucketAst,
24
24
  type FieldAstV2,
25
+ type KernelFieldFacts,
25
26
  type ParsedField,
26
27
  } from "./schema-ast-v2.js"
27
28
 
@@ -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
- case "MinLength":
429
- case "Between":
486
+ case "MaxLength": {
487
+ const max = parseNumericTypeArg(current.typeArguments?.[1], sourceFile)
488
+ if (max !== undefined) {
489
+ flags.checkConstraint = mergeCheckConstraint(
490
+ flags.checkConstraint,
491
+ `char_length("{name}") <= ${max}`,
492
+ )
493
+ }
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: { cardinality: "belongsTo", target: flags.relationTarget! },
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
- case "HasOne":
550
+ }
551
+ case "HasOne": {
452
552
  flags.relationCardinality = "one"
453
- flags.relationTarget = current.typeArguments?.[0]?.getText(sourceFile).replace(/\W/g, "") ?? "unknown"
553
+ flags.relationTarget = relationTargetFromTypeArg(current.typeArguments?.[0], sourceFile)
554
+ const relOpts = parseRelationOptions(current.typeArguments?.[1], sourceFile)
454
555
  return emitField({
455
556
  kind: "relation",
456
- kernel: { cardinality: "hasOne", target: flags.relationTarget },
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
- case "HasMany":
461
- case "ManyToMany":
565
+ }
566
+ case "HasMany": {
462
567
  flags.relationCardinality = "many"
463
- flags.relationTarget = current.typeArguments?.[0]?.getText(sourceFile).replace(/\W/g, "") ?? "unknown"
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: { cardinality: "hasMany", target: flags.relationTarget },
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
+ })