@supatype/cli 0.3.0 → 0.3.2

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 (115) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +161 -154
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/api-config-cache.d.ts +20 -0
  5. package/dist/api-config-cache.d.ts.map +1 -1
  6. package/dist/api-config-cache.js +27 -0
  7. package/dist/api-config-cache.js.map +1 -1
  8. package/dist/augmentation-generator.d.ts.map +1 -1
  9. package/dist/augmentation-generator.js +63 -18
  10. package/dist/augmentation-generator.js.map +1 -1
  11. package/dist/cli-version-embedded.js +1 -1
  12. package/dist/client-generator.d.ts +3 -0
  13. package/dist/client-generator.d.ts.map +1 -0
  14. package/dist/client-generator.js +108 -0
  15. package/dist/client-generator.js.map +1 -0
  16. package/dist/commands/admin.d.ts.map +1 -1
  17. package/dist/commands/admin.js +21 -4
  18. package/dist/commands/admin.js.map +1 -1
  19. package/dist/commands/functions.d.ts.map +1 -1
  20. package/dist/commands/functions.js +2 -2
  21. package/dist/commands/functions.js.map +1 -1
  22. package/dist/commands/init.d.ts.map +1 -1
  23. package/dist/commands/init.js +7 -10
  24. package/dist/commands/init.js.map +1 -1
  25. package/dist/commands/keys.d.ts +35 -0
  26. package/dist/commands/keys.d.ts.map +1 -1
  27. package/dist/commands/keys.js +90 -6
  28. package/dist/commands/keys.js.map +1 -1
  29. package/dist/commands/push.d.ts.map +1 -1
  30. package/dist/commands/push.js +23 -13
  31. package/dist/commands/push.js.map +1 -1
  32. package/dist/commands/update.d.ts.map +1 -1
  33. package/dist/commands/update.js +19 -1
  34. package/dist/commands/update.js.map +1 -1
  35. package/dist/db-port.d.ts +36 -0
  36. package/dist/db-port.d.ts.map +1 -0
  37. package/dist/db-port.js +90 -0
  38. package/dist/db-port.js.map +1 -0
  39. package/dist/dev-compose.d.ts +13 -0
  40. package/dist/dev-compose.d.ts.map +1 -1
  41. package/dist/dev-compose.js +205 -18
  42. package/dist/dev-compose.js.map +1 -1
  43. package/dist/functions-context-refresh.d.ts +4 -0
  44. package/dist/functions-context-refresh.d.ts.map +1 -0
  45. package/dist/functions-context-refresh.js +26 -0
  46. package/dist/functions-context-refresh.js.map +1 -0
  47. package/dist/functions-deno-types.d.ts +9 -0
  48. package/dist/functions-deno-types.d.ts.map +1 -1
  49. package/dist/functions-deno-types.js +21 -1
  50. package/dist/functions-deno-types.js.map +1 -1
  51. package/dist/gitignore.d.ts +3 -1
  52. package/dist/gitignore.d.ts.map +1 -1
  53. package/dist/gitignore.js +28 -6
  54. package/dist/gitignore.js.map +1 -1
  55. package/dist/model-cache.d.ts +27 -0
  56. package/dist/model-cache.d.ts.map +1 -1
  57. package/dist/model-cache.js +61 -0
  58. package/dist/model-cache.js.map +1 -1
  59. package/dist/schema-ast-v2.d.ts.map +1 -1
  60. package/dist/schema-ast-v2.js +17 -8
  61. package/dist/schema-ast-v2.js.map +1 -1
  62. package/dist/self-host-compose.d.ts +10 -2
  63. package/dist/self-host-compose.d.ts.map +1 -1
  64. package/dist/self-host-compose.js +114 -9
  65. package/dist/self-host-compose.js.map +1 -1
  66. package/dist/strict.d.ts +29 -0
  67. package/dist/strict.d.ts.map +1 -0
  68. package/dist/strict.js +37 -0
  69. package/dist/strict.js.map +1 -0
  70. package/dist/studio-dev-server.d.ts +12 -2
  71. package/dist/studio-dev-server.d.ts.map +1 -1
  72. package/dist/studio-dev-server.js +12 -2
  73. package/dist/studio-dev-server.js.map +1 -1
  74. package/dist/type-extractor.d.ts.map +1 -1
  75. package/dist/type-extractor.js +8 -2
  76. package/dist/type-extractor.js.map +1 -1
  77. package/dist/type-generation.d.ts +14 -0
  78. package/dist/type-generation.d.ts.map +1 -1
  79. package/dist/type-generation.js +41 -2
  80. package/dist/type-generation.js.map +1 -1
  81. package/package.json +2 -2
  82. package/src/api-config-cache.ts +30 -0
  83. package/src/augmentation-generator.ts +75 -18
  84. package/src/cli-version-embedded.ts +1 -1
  85. package/src/client-generator.ts +122 -0
  86. package/src/commands/admin.ts +23 -6
  87. package/src/commands/functions.ts +5 -2
  88. package/src/commands/init.ts +10 -10
  89. package/src/commands/keys.ts +112 -7
  90. package/src/commands/push.ts +25 -15
  91. package/src/commands/update.ts +20 -1
  92. package/src/db-port.ts +99 -0
  93. package/src/dev-compose.ts +244 -19
  94. package/src/functions-context-refresh.ts +25 -0
  95. package/src/functions-deno-types.ts +24 -1
  96. package/src/gitignore.ts +31 -10
  97. package/src/model-cache.ts +48 -0
  98. package/src/schema-ast-v2.ts +17 -8
  99. package/src/self-host-compose.ts +115 -9
  100. package/src/strict.ts +40 -0
  101. package/src/studio-dev-server.ts +12 -2
  102. package/src/type-extractor.ts +8 -2
  103. package/src/type-generation.ts +47 -2
  104. package/tests/ast-derived-outputs.test.ts +92 -0
  105. package/tests/augmentation-generator.test.ts +127 -1
  106. package/tests/client-generator.test.ts +103 -0
  107. package/tests/db-port.test.ts +128 -0
  108. package/tests/external-database-compose.test.ts +8 -1
  109. package/tests/functions-context-refresh.test.ts +86 -0
  110. package/tests/gitignore-secrets.test.ts +90 -0
  111. package/tests/keys-write.test.ts +148 -0
  112. package/tests/runtime-contract.test.ts +115 -2
  113. package/tests/strict.test.ts +78 -0
  114. package/tests/type-extractor.test.ts +34 -0
  115. package/tsconfig.tsbuildinfo +1 -1
@@ -227,6 +227,15 @@ export interface ExtractedSchemaAstV2 {
227
227
  }
228
228
  }
229
229
 
230
+ /**
231
+ * The Postgres type each field kind maps to, as published in the AST.
232
+ *
233
+ * A contract, not a hint: consumers generate parsers and casts from it. `money` and `decimal` said
234
+ * `TEXT` here while the engine creates `NUMERIC(19,4)` and `NUMERIC`, so anyone reading this wrote
235
+ * code that rejected every value the column returns. The engine's own `default_pg_type` agreed
236
+ * with the mistake for `money`, and was already right for `decimal`, which is what made it look
237
+ * considered.
238
+ */
230
239
  const DEFAULT_DB_BY_KIND: Partial<Record<FieldKind, Partial<DbFieldAnnotations>>> = {
231
240
  text: { pgType: "TEXT" },
232
241
  richText: { pgType: "JSONB" },
@@ -244,17 +253,17 @@ const DEFAULT_DB_BY_KIND: Partial<Record<FieldKind, Partial<DbFieldAnnotations>>
244
253
  slug: { pgType: "TEXT" },
245
254
  enum: { pgType: "TEXT" },
246
255
  json: { pgType: "JSONB" },
247
- decimal: { pgType: "TEXT" },
256
+ decimal: { pgType: "NUMERIC" },
248
257
  bytes: { pgType: "BYTEA" },
249
258
  serial: { pgType: "SERIAL" },
250
259
  bigSerial: { pgType: "BIGSERIAL" },
251
- money: { pgType: "TEXT" },
252
- ip: { pgType: "TEXT" },
253
- cidr: { pgType: "TEXT" },
254
- macaddr: { pgType: "TEXT" },
255
- xml: { pgType: "TEXT" },
256
- tsQuery: { pgType: "TEXT" },
257
- tsVector: { pgType: "TEXT" },
260
+ money: { pgType: "NUMERIC(19,4)" },
261
+ ip: { pgType: "INET" },
262
+ cidr: { pgType: "CIDR" },
263
+ macaddr: { pgType: "MACADDR" },
264
+ xml: { pgType: "XML" },
265
+ tsQuery: { pgType: "TSQUERY" },
266
+ tsVector: { pgType: "TSVECTOR" },
258
267
  color: { pgType: "TEXT" },
259
268
  array: { pgType: "ARRAY" },
260
269
  image: { pgType: "JSONB" },
@@ -17,7 +17,8 @@ import {
17
17
  import { hasEngineOverride, hasStudioOverride, pinnedVersion, fetchLatestVersion, VERSION_PIN_LOCAL } from "./binary-cache.js"
18
18
  import { buildKongDeclarative } from "./kong-config.js"
19
19
  import { keyspaceInPostgres } from "./cache-provider.js"
20
- import { readEnvFile } from "./env-file.js"
20
+ import { STUDIO_DEV_PORT } from "./studio-dev-server.js"
21
+ import { hasEnvValue, readEnvFile } from "./env-file.js"
21
22
  import { fieldMaskingTierFromProject, type FieldMaskingTier } from "./field-masking-tier.js"
22
23
  import { projectHasVersionedModels } from "./model-versioning.js"
23
24
 
@@ -285,8 +286,16 @@ function postgrestDatabaseUrl(config: SupatypeProjectConfig): string {
285
286
  return `postgresql://authenticator:${password}@${parsed.hostname}${port}${parsed.pathname}${parsed.search}`
286
287
  }
287
288
 
288
- /** Host Vite dev server as seen from Kong inside Docker Compose. */
289
- export const COMPOSE_STUDIO_HOST_URL = "http://host.docker.internal:3002"
289
+ /**
290
+ * Host Vite dev server as seen from Kong inside Docker Compose.
291
+ *
292
+ * Derived from STUDIO_DEV_PORT rather than repeating the number, because the two are one decision.
293
+ * Held separately they drift, and the drift is invisible: Vite binds the new port, Kong keeps
294
+ * proxying to the old one, and `/studio/` serves whatever else happens to be listening there. On
295
+ * the machine this was found, that was an unrelated Next.js app, and every Studio view failed with
296
+ * a missing sign-in form rather than anything naming a port.
297
+ */
298
+ export const COMPOSE_STUDIO_HOST_URL = `http://host.docker.internal:${STUDIO_DEV_PORT}`
290
299
 
291
300
  /** Studio container: always Docker Hub unless SUPATYPE_STUDIO_IMAGE is set in .env. */
292
301
  function studioServiceBlock(): string {
@@ -395,25 +404,41 @@ ${studioService}
395
404
  : ` - server
396
405
  - studio
397
406
  - control-plane`
398
- const publishDbToHost = !devLocal || hasEngineOverride(config)
407
+ // In dev the database is published only when something on the host needs to reach it, which is
408
+ // normally a host engine build. A project that names a port is asking for one too: without this,
409
+ // `SUPATYPE_DEV_DB_PORT` was honoured for the number and ignored for whether the port existed,
410
+ // so a seed connecting over TCP got ECONNREFUSED and nothing said why.
411
+ const dbPortRequested = hasEnvValue(cwd, "SUPATYPE_DEV_DB_PORT")
412
+ const publishDbToHost = !devLocal || hasEngineOverride(config) || dbPortRequested
399
413
  const dbPorts = publishDbToHost
400
414
  ? devLocal
401
415
  ? ` ports:
402
416
  - "127.0.0.1:\${SUPATYPE_DEV_DB_PORT:-54329}:5432"
403
417
  `
404
418
  : ` ports:
405
- - "5432:5432"
419
+ - "\${SUPATYPE_DB_PORT:-5432}:5432"
406
420
  `
407
421
  : ""
422
+ // Host ports, every one of them overridable.
423
+ //
424
+ // Kong's has been `${SUPATYPE_KONG_PORT:-18473}` for as long as `supatype dev` has picked a free
425
+ // one per project and written it back to .env. These three were fixed literals, so the second
426
+ // project on a machine could not start: Docker refuses the bind and compose reports only that
427
+ // the stack would not come up, naming no port. Two projects side by side is the normal case
428
+ // here (this repository ships eight examples), so a literal is the wrong default.
429
+ //
430
+ // Defaults are the previous literals, so a project with none of these set behaves exactly as
431
+ // before. Allocating them per project the way the Kong port is allocated is the follow-up; this
432
+ // makes a second stack possible rather than automatic.
408
433
  const serverPorts = devLocal
409
434
  ? ""
410
435
  : ` ports:
411
- - "9999:9999"
436
+ - "\${SUPATYPE_SERVER_PORT:-9999}:9999"
412
437
  `
413
438
  const seaweedPorts = devLocal
414
439
  ? ""
415
440
  : ` ports:
416
- - "8333:8333"
441
+ - "\${SUPATYPE_SEAWEEDFS_PORT:-8333}:8333"
417
442
  `
418
443
  // One source for the credentials: the server is configured with them and the storage service is
419
444
  // handed them, and a mismatch does not fail at start, it fails at the first upload.
@@ -426,6 +451,15 @@ ${studioService}
426
451
  volumes:
427
452
  - storage-data:/data
428
453
  - ${SEAWEED_CONFIG_MOUNT}:/etc/seaweedfs/s3.json:ro
454
+ healthcheck:
455
+ # Any HTTP status line means the S3 endpoint is listening, which is the whole question.
456
+ # Success cannot be "HTTP 200": an unauthenticated GET on the root answers 403 by design,
457
+ # because the anonymous identity is deliberately absent from s3.json.
458
+ test: ["CMD-SHELL", "wget -q -S -O /dev/null http://127.0.0.1:8333 2>&1 | grep -q 'HTTP/'"]
459
+ interval: 3s
460
+ timeout: 3s
461
+ retries: 20
462
+ start_period: 5s
429
463
  ${seaweedPorts}`
430
464
  const kongTlsEnv = tlsEnabled
431
465
  ? ` KONG_PROXY_LISTEN: "0.0.0.0:8000, 0.0.0.0:8443 ssl"
@@ -480,6 +514,21 @@ ${keyspaceInPg ? "" : " valkey-data:\n"}`
480
514
  `
481
515
  const dbDependency = external ? "" : ` depends_on:\n${dbDependencyClause}`
482
516
 
517
+ // Storage waits for the object store, not only the database.
518
+ //
519
+ // seaweedfs had no healthcheck and nothing depended on it, so compose started it alongside
520
+ // storage rather than before it. Storage would then accept a bucket creation before seaweedfs
521
+ // was listening, and every bucket in the schema failed with `connect ECONNREFUSED <ip>:8333`
522
+ // while the metadata row was written anyway. Measured on a live stack: seaweedfs started twelve
523
+ // minutes after storage with RestartCount 0, so it was ordered late rather than crashing.
524
+ //
525
+ // A later push succeeds, which is exactly what made it read as an intermittent mystery. It
526
+ // lands on the first push after a stack comes up, which is the first push a new user ever runs.
527
+ const storageDependency = ` depends_on:
528
+ ${dbDependencyClause} seaweedfs:
529
+ condition: service_healthy
530
+ `
531
+
483
532
  // Realtime, omitted entirely when the project has turned it off.
484
533
  //
485
534
  // Not started-and-disabled: the service degrades gracefully on its own (it reports the reason on
@@ -560,6 +609,29 @@ ${dbDependency}`
560
609
  SUPATYPE_KEYSPACE_KEYS: "200000"
561
610
  SUPATYPE_KEYSPACE_RING_MB: "16"
562
611
  SUPATYPE_KEYSPACE_ROWCACHE_MB: "64"
612
+ # Mode B, the row cache itself. The segment above is reserved either way; these two decide
613
+ # whether anything decodes into it or serves from it, and the image refuses readthrough
614
+ # without decode because that pair serves stale rows forever rather than merely wasting
615
+ # memory.
616
+ #
617
+ # From .env rather than a literal, because only a push can answer this: the switches follow
618
+ # whether any model declares \`cache: { rows: true }\`, and \`self-host compose render\` runs
619
+ # with no schema in hand. Default off, so a project that declares no row cache does not pin
620
+ # WAL behind a replication slot it never reads.
621
+ SUPATYPE_KEYSPACE_ROWCACHE_DECODE: "\${SUPATYPE_KEYSPACE_ROWCACHE_DECODE:-0}"
622
+ SUPATYPE_KEYSPACE_ROWCACHE_READTHROUGH: "\${SUPATYPE_KEYSPACE_ROWCACHE_READTHROUGH:-0}"
623
+ # Both decoders this stack runs, because the image's setting REPLACES the allowlist.
624
+ #
625
+ # Turning the row cache on makes the entrypoint write
626
+ # \`output_plugin_libraries = 'supacache_keys'\`, and PostgreSQL then refuses every other
627
+ # plugin. Realtime decodes with wal2json, so a project that declared \`cache: { rows: true }\`
628
+ # silently lost realtime: the service stayed up, answered /health/ready with 200, and logged
629
+ # \`library "wal2json" may not be used as an output plugin\` once a second while every
630
+ # subscription reported SUBSCRIBED and delivered nothing.
631
+ #
632
+ # Named here rather than left to the image because only this file knows both features are in
633
+ # the same stack.
634
+ SUPATYPE_KEYSPACE_OUTPUT_PLUGIN_LIBRARIES: "supacache_keys, wal2json"
563
635
  `
564
636
  : ""
565
637
 
@@ -642,7 +714,7 @@ ${dbDependency}
642
714
  S3_ACCESS_KEY: ${OBJECT_STORE_ACCESS_KEY}
643
715
  S3_SECRET_KEY: ${OBJECT_STORE_SECRET_KEY}
644
716
  S3_FORCE_PATH_STYLE: "true"
645
- ${dbDependency}
717
+ ${storageDependency}
646
718
  functions-worker:
647
719
  image: \${SUPATYPE_FUNCTIONS_WORKER_IMAGE:-supatype/functions-worker:latest}
648
720
  expose:
@@ -671,6 +743,18 @@ ${dbDependency}
671
743
  # each one able to read past every access rule in the schema.
672
744
  SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY:-}
673
745
  SUPATYPE_SERVICE_ROLE_ROUTES: "${serviceRoleRoutes(config).join(",")}"
746
+ # A direct database connection for functions, off unless asked for.
747
+ #
748
+ # The worker reads SUPATYPE_DB_URL and exposes it as \`ctx.dbUrl\`, and nothing here ever set
749
+ # it, so the field was permanently undefined on self-host and a function reaching for it got
750
+ # no value and no explanation.
751
+ #
752
+ # Deliberately its own variable rather than the project's DATABASE_URL. That one is the owner
753
+ # DSN every service already uses, and wiring it through by default would hand every function
754
+ # a connection that bypasses access rules, field masking and model hooks, none of which live
755
+ # in the database. Naming a separate variable makes it a decision: set it to the owner URL
756
+ # and accept that, or to a role you restricted yourself.
757
+ SUPATYPE_DB_URL: \${SUPATYPE_FUNCTIONS_DB_URL:-}
674
758
  STRIPE_SECRET_KEY: \${STRIPE_SECRET_KEY:-}
675
759
  STRIPE_WEBHOOK_SECRET: \${STRIPE_WEBHOOK_SECRET:-}
676
760
  SITE_URL: \${SITE_URL:-\${API_EXTERNAL_URL:-${externalUrlFallback}}}
@@ -696,6 +780,12 @@ ${realtimeBlock}
696
780
  ${dbDependency}
697
781
  server:
698
782
  image: \${SUPATYPE_SERVER_IMAGE:-\${SUPATYPE_AUTH_IMAGE:-supatype/server:latest}}
783
+ # host.docker.internal is a Docker Desktop name. On Linux it does not resolve unless it is
784
+ # mapped, so a project proxying the site or Studio to something on the host worked on macOS
785
+ # and Windows and failed on Linux with nothing reaching the app. host-gateway is Docker's own
786
+ # alias for the host, and needs 20.10, which this stack already requires.
787
+ extra_hosts:
788
+ - "host.docker.internal:host-gateway"
699
789
  # The server runs its migrations at boot on a connection of their own,
700
790
  # and that path does not wait out a database that is still in recovery:
701
791
  # it exits. Waiting for db to report healthy is not enough, because
@@ -705,7 +795,17 @@ ${dbDependency}
705
795
  # stays visible instead of hiding in a crash loop.
706
796
  restart: on-failure:5
707
797
  ${serverPorts} volumes:
798
+ # The project is read-only: the server reads the schema, the manifest and the functions, and
799
+ # has no business editing any of them.
708
800
  - ${projectMount}:/project:ro
801
+ # .supatype is the exception, and only because one file in it is not project source.
802
+ # api-config.json is the operator's runtime state: which tables have caching switched on,
803
+ # the project TTL, max_rows. PATCH /admin/v1/config/rest writes it, which is what Studio's
804
+ # cache panel and the CLI's cache commands call. Under the read-only mount alone that PATCH
805
+ # fails with "read-only file system", so a cache a model declares can be read back as
806
+ # declared and never actually switched on. A narrower bind than making the whole project
807
+ # writable, because the rest of the tree keeps the guarantee.
808
+ - ${projectMount}/.supatype:/project/.supatype
709
809
  working_dir: /project
710
810
  environment:
711
811
  SUPATYPE_MODE: ${devLocal ? "dev" : "standalone"}
@@ -754,7 +854,7 @@ ${appEnv}
754
854
  SUPATYPE_SMTP_ADMIN_EMAIL: \${SUPATYPE_SMTP_ADMIN_EMAIL:-}
755
855
  SUPATYPE_SMTP_SENDER_NAME: \${SUPATYPE_SMTP_SENDER_NAME:-}
756
856
  SUPATYPE_DISABLE_SIGNUP: \${DISABLE_SIGNUP:-false}
757
- ${devLocal ? " STUDIO_OPEN_DEV: \"1\"\n" : ""}
857
+ ${devLocal ? " STUDIO_OPEN_DEV: \"${STUDIO_OPEN_DEV:-1}\"\n" : ""}
758
858
  depends_on:
759
859
  ${dbDependencyClause}${keyspaceInPg ? "" : " valkey:\n condition: service_started\n"} postgrest:
760
860
  condition: service_started
@@ -775,6 +875,12 @@ ${objectStoreBlock}
775
875
  working_dir: /project
776
876
  ${dbDependency}${studioBlock}${valkeyBlock}${tlsHintComment} kong:
777
877
  image: kong:3.6
878
+ # host.docker.internal is a Docker Desktop name. On Linux it does not resolve unless it is
879
+ # mapped, so a project proxying the site or Studio to something on the host worked on macOS
880
+ # and Windows and failed on Linux with nothing reaching the app. host-gateway is Docker's own
881
+ # alias for the host, and needs 20.10, which this stack already requires.
882
+ extra_hosts:
883
+ - "host.docker.internal:host-gateway"
778
884
  environment:
779
885
  KONG_DATABASE: "off"
780
886
  KONG_DECLARATIVE_CONFIG: /etc/kong/kong.yml
package/src/strict.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Strict mode: a degraded path is a failure rather than a warning.
3
+ *
4
+ * Every fallback in this CLI exists because giving up would serve a person at a terminal worse
5
+ * than carrying on: the in-compose engine when the CDN cannot be reached, SHA256-only verification
6
+ * when no key is embedded, a bucket that could not be reached from the host but works from inside
7
+ * the network. Each is the right default interactively.
8
+ *
9
+ * In CI the trade runs the other way. A run that limps reports success, and the thing it quietly
10
+ * skipped is precisely the thing nobody tests again.
11
+ *
12
+ * This is not hypothetical. On 2026-09-24 three integration jobs failed on assertions about
13
+ * validators and served SPAs, and the cause in all three was an engine download refused twenty
14
+ * lines earlier and warned about. The same fallback made a local from-zero run look like a clean
15
+ * pass while the host engine path was never exercised at all.
16
+ */
17
+
18
+ /** True when the caller has asked for degraded paths to fail. */
19
+ export function strict(): boolean {
20
+ return process.env["SUPATYPE_STRICT"] === "1"
21
+ }
22
+
23
+ /**
24
+ * Report a path that worked less well than intended.
25
+ *
26
+ * Throws under `SUPATYPE_STRICT=1`, so a CI job cannot pass over it. Warns otherwise, because
27
+ * interactively a working stack and a note beats an abort.
28
+ *
29
+ * `what` names the capability that degraded, not the error: the reader needs to know which feature
30
+ * they are now without. `detail` carries the cause.
31
+ */
32
+ export function degraded(what: string, detail: string): void {
33
+ const message = `${what}: ${detail}`
34
+ if (strict()) {
35
+ throw new Error(
36
+ `${message}\nSUPATYPE_STRICT=1 is set, so a degraded path fails rather than warns.`,
37
+ )
38
+ }
39
+ console.warn(`[supatype] ${message}`)
40
+ }
@@ -2,8 +2,18 @@ import { existsSync } from "node:fs"
2
2
  import { join, resolve } from "node:path"
3
3
  import { ProcessManager } from "./process-manager.js"
4
4
 
5
- /** Vite dev server port when `overrides.studio` is set. */
6
- export const STUDIO_DEV_PORT = 3002
5
+ /**
6
+ * Vite dev server port when `overrides.studio` is set.
7
+ *
8
+ * Overridable because it is bound with `--strictPort`, so anything else already on 3002 does not
9
+ * make Vite pick another port, it makes Studio fail to start and be restarted every thirty seconds
10
+ * for the life of the session. 3002 is an ordinary port for a local app to be sitting on, and the
11
+ * only signal is a Vite stack trace in among the compose output.
12
+ *
13
+ * Only the `overrides.studio` path is affected, so this is a contributor's papercut rather than a
14
+ * user's: an ordinary `supatype dev` serves Studio from its container.
15
+ */
16
+ export const STUDIO_DEV_PORT = Number(process.env["SUPATYPE_STUDIO_DEV_PORT"]) || 3002
7
17
 
8
18
  export interface StudioDevServerOptions {
9
19
  cwd: string
@@ -1055,7 +1055,10 @@ function parseScalarType(
1055
1055
  const assetOpts = parseAssetFieldOptions(typeNode.typeArguments?.[1], sourceFile)
1056
1056
  return attachStorageFieldMeta(
1057
1057
  scalar("file", {
1058
- db: { pgType: "TEXT" },
1058
+ // JSONB, not TEXT. The column holds { bucket, path }, this package's own
1059
+ // DEFAULT_DB_BY_KIND says JSONB, and the engine honours an explicit annotation, so
1060
+ // TEXT here was one code path from a text column with a JSON object written into it.
1061
+ db: { pgType: "JSONB" },
1059
1062
  kernel: { bucket, ...(assetOpts.localized && { localized: true }) },
1060
1063
  }),
1061
1064
  bucket,
@@ -1067,7 +1070,10 @@ function parseScalarType(
1067
1070
  const assetOpts = parseAssetFieldOptions(typeNode.typeArguments?.[1], sourceFile)
1068
1071
  return attachStorageFieldMeta(
1069
1072
  scalar("image", {
1070
- db: { pgType: "TEXT" },
1073
+ // JSONB, not TEXT. The column holds { bucket, path }, this package's own
1074
+ // DEFAULT_DB_BY_KIND says JSONB, and the engine honours an explicit annotation, so
1075
+ // TEXT here was one code path from a text column with a JSON object written into it.
1076
+ db: { pgType: "JSONB" },
1071
1077
  kernel: { bucket, ...(assetOpts.localized && { localized: true }) },
1072
1078
  }),
1073
1079
  bucket,
@@ -13,9 +13,13 @@
13
13
  */
14
14
 
15
15
  import { mkdirSync, writeFileSync } from "node:fs"
16
- import { dirname, resolve } from "node:path"
16
+ import { dirname, join, resolve, sep } from "node:path"
17
17
  import { generateClientAugmentation } from "./augmentation-generator.js"
18
18
  import { ensureEngine, engineRequest } from "./engine-client.js"
19
+ import { generateProjectClient } from "./client-generator.js"
20
+
21
+ /** The client a project imports, beside the generated types. */
22
+ const PROJECT_CLIENT_FILENAME = "client.ts"
19
23
 
20
24
  export interface GenerateTypesRequest {
21
25
  cwd: string
@@ -48,7 +52,27 @@ export async function writeGeneratedTypes(req: GenerateTypesRequest): Promise<st
48
52
  written.push(`Types written to ${req.typesPath}`)
49
53
  }
50
54
 
51
- // Generated locally from the AST, so it needs no engine round trip.
55
+ written.push(...writeAstDerivedOutputs(req))
56
+
57
+ return written
58
+ }
59
+
60
+ /**
61
+ * The generated files that come from the AST alone, with no engine round trip.
62
+ *
63
+ * Shared because three commands need them and only one was writing them. `push` and `generate`
64
+ * call {@link writeGeneratedTypes}; `dev` has its own type generation, for good reasons it should
65
+ * keep, and so never wrote these at all. The result was a project developed entirely through
66
+ * `supatype dev` that never received `index.d.ts`, so the module augmentation that makes
67
+ * `createClient` typed without a generic never happened, and never received the generated client,
68
+ * so every exact-value column fell back to asking the API.
69
+ *
70
+ * Kept separate from the types branch above rather than folding `dev` into it: that branch slices
71
+ * the engine's output from a marker and treats a failure as fatal, and `dev` must do neither.
72
+ */
73
+ export function writeAstDerivedOutputs(req: GenerateTypesRequest): string[] {
74
+ const written: string[] = []
75
+
52
76
  if (req.clientPath !== undefined && req.clientPath !== "") {
53
77
  const outPath = resolve(req.cwd, req.clientPath)
54
78
  mkdirSync(dirname(outPath), { recursive: true })
@@ -56,5 +80,26 @@ export async function writeGeneratedTypes(req: GenerateTypesRequest): Promise<st
56
80
  written.push(`Client augmentation written to ${req.clientPath}`)
57
81
  }
58
82
 
83
+ // The client the project imports. One file carrying the augmentation, the exact-value columns
84
+ // and a re-export of `createClient`, so an app writes a single import and tsconfig cannot lose
85
+ // any of it. See `client-generator.ts` for what each part replaced.
86
+ // Beside `output.client` when set, otherwise beside `output.types`. A project that configured
87
+ // only types still gets a usable client: this one file carries the augmentation and the exact
88
+ // columns as well as `createClient`, so without it the project has types and no way to use them
89
+ // that knows anything about its schema.
90
+ const clientDir = req.clientPath ?? req.typesPath
91
+ if (clientDir !== undefined && clientDir !== "") {
92
+ const relative = join(dirname(clientDir), PROJECT_CLIENT_FILENAME)
93
+ const outPath = resolve(req.cwd, relative)
94
+ mkdirSync(dirname(outPath), { recursive: true })
95
+ writeFileSync(outPath, generateProjectClient(req.ast), "utf8")
96
+ written.push(`Client written to ${toPosix(relative)}`)
97
+ }
98
+
59
99
  return written
60
100
  }
101
+
102
+ /** Reported with forward slashes, so the message reads the same on every platform. */
103
+ function toPosix(path: string): string {
104
+ return path.split(sep).join("/")
105
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The generated files that need no engine, and which `dev` never wrote.
3
+ *
4
+ * `push` and `generate` go through `writeGeneratedTypes`. `dev` has its own type generation, which
5
+ * it should keep: it slices the engine's output from a marker and treats a failure as a warning
6
+ * rather than killing the session. What it should not have had is its own idea of which files
7
+ * exist, and the result was that a project developed entirely through `supatype dev` never
8
+ * received the module augmentation that types `createClient` without a generic.
9
+ *
10
+ * Both files come from the AST alone, so they are shared and all three commands write them.
11
+ */
12
+ import { describe, expect, it, beforeEach, afterEach } from "vitest"
13
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
14
+ import { join } from "node:path"
15
+ import { tmpdir } from "node:os"
16
+ import { writeAstDerivedOutputs } from "../src/type-generation.js"
17
+
18
+ const ast = {
19
+ models: [
20
+ {
21
+ name: "JobPost",
22
+ annotations: { db: { tableName: "job_post" } },
23
+ fields: {
24
+ id: { kind: "uuid", required: true },
25
+ views: { kind: "bigInt", required: true },
26
+ },
27
+ },
28
+ ],
29
+ }
30
+
31
+ let cwd: string
32
+
33
+ beforeEach(() => {
34
+ cwd = mkdtempSync(join(tmpdir(), "supatype-outputs-"))
35
+ })
36
+
37
+ afterEach(() => {
38
+ rmSync(cwd, { recursive: true, force: true })
39
+ })
40
+
41
+ describe("writeAstDerivedOutputs", () => {
42
+ it("writes the augmentation and the project client together", () => {
43
+ const written = writeAstDerivedOutputs({
44
+ cwd,
45
+ ast,
46
+ typesPath: "supatype/generated/database.ts",
47
+ clientPath: "supatype/generated/index.d.ts",
48
+ })
49
+
50
+ expect(existsSync(join(cwd, "supatype/generated/index.d.ts"))).toBe(true)
51
+ expect(existsSync(join(cwd, "supatype/generated/client.ts"))).toBe(true)
52
+ expect(written).toHaveLength(2)
53
+ })
54
+
55
+ it("puts the client beside the generated types", () => {
56
+ // An app imports it by a relative path, so it has to land next to the rest of the generated
57
+ // output rather than at the project root.
58
+ writeAstDerivedOutputs({ cwd, ast, clientPath: "supatype/generated/index.d.ts" })
59
+ const body = readFileSync(join(cwd, "supatype/generated/client.ts"), "utf8")
60
+
61
+ expect(body).toContain('export * from "@supatype/client"')
62
+ expect(body).toContain('declare module "@supatype/client"')
63
+ expect(body).toContain('"views": "bigint"')
64
+ })
65
+
66
+ it("writes nothing when a project configured no output at all", () => {
67
+ // `push` must not start creating files in projects that never asked for any.
68
+ expect(writeAstDerivedOutputs({ cwd, ast })).toEqual([])
69
+ expect(existsSync(join(cwd, "supatype"))).toBe(false)
70
+ })
71
+
72
+ it("needs no engine, so it can run when type generation could not", () => {
73
+ // This is why it is separate: in `dev` the engine may be unavailable, and the augmentation and
74
+ // the client should still reach the project.
75
+ const written = writeAstDerivedOutputs({ cwd, ast, clientPath: "supatype/generated/index.d.ts" })
76
+ expect(written.some((line) => line.includes("Client written"))).toBe(true)
77
+ })
78
+ })
79
+
80
+ describe("a project that configured only types", () => {
81
+ it("still gets a client, beside the types", () => {
82
+ // Otherwise it has generated types and no way to use them that knows its schema: the
83
+ // augmentation and the exact-value columns both travel in this file.
84
+ const dir = mkdtempSync(join(tmpdir(), "supatype-types-only-"))
85
+ try {
86
+ writeAstDerivedOutputs({ cwd: dir, ast, typesPath: "supatype/generated/database.ts" })
87
+ expect(existsSync(join(dir, "supatype/generated/client.ts"))).toBe(true)
88
+ } finally {
89
+ rmSync(dir, { recursive: true, force: true })
90
+ }
91
+ })
92
+ })
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { generateClientAugmentation } from "../src/augmentation-generator.js"
2
+ import { generateClientAugmentation, generateRowType } from "../src/augmentation-generator.js"
3
3
 
4
4
  describe("generateClientAugmentation", () => {
5
5
  it("emits deterministic output independent of model order", () => {
@@ -125,3 +125,129 @@ describe("generateClientAugmentation", () => {
125
125
  expect(out).toContain("notes: Record<string, unknown> | null")
126
126
  })
127
127
  })
128
+
129
+ describe("exact-value numeric kinds", () => {
130
+ /**
131
+ * A column whose value does not survive an IEEE-754 double must not be typed as one.
132
+ *
133
+ * `decimal` used to emit `number` here while `@supatype/types` declared `Decimal<P, S>` as
134
+ * `string` and the engine emitted `string`, so the ambient client type contradicted both and
135
+ * told a caller a rounded value was exact.
136
+ */
137
+ it("types money, decimal and bigInt so the exact value survives", () => {
138
+ const out = generateClientAugmentation({
139
+ models: [
140
+ {
141
+ name: "Invoice",
142
+ annotations: { db: { tableName: "invoice", indexes: [] } },
143
+ fields: {
144
+ total: { kind: "money", required: true },
145
+ rate: { kind: "decimal", required: true },
146
+ ref: { kind: "bigInt", required: true },
147
+ qty: { kind: "integer", required: true },
148
+ },
149
+ },
150
+ ],
151
+ })
152
+
153
+ expect(out).toContain("total: string")
154
+ expect(out).toContain("rate: string")
155
+ expect(out).toContain("ref: bigint")
156
+ // The kinds that genuinely fit a double are untouched.
157
+ expect(out).toContain("qty: number")
158
+ })
159
+ })
160
+
161
+ describe("relation fields", () => {
162
+ const talk = {
163
+ models: [
164
+ {
165
+ name: "Talk",
166
+ annotations: { db: { tableName: "talk", indexes: [] } },
167
+ fields: {
168
+ title: { kind: "text", required: true },
169
+ speaker: {
170
+ kind: "relation",
171
+ cardinality: "belongsTo",
172
+ target: "Speaker",
173
+ annotations: { db: { foreignKey: "speaker_id" } },
174
+ },
175
+ venue: {
176
+ kind: "relation",
177
+ cardinality: "belongsTo",
178
+ target: "Room",
179
+ required: true,
180
+ annotations: { db: { foreignKey: "room_id" } },
181
+ },
182
+ authUser: { kind: "relation", cardinality: "belongsTo", target: "User" },
183
+ slots: { kind: "relation", cardinality: "hasMany", target: "Slot" },
184
+ },
185
+ },
186
+ ],
187
+ }
188
+
189
+ /**
190
+ * A `belongsTo` is a foreign key column, not an embedded object.
191
+ *
192
+ * The generator used to group `relation` with geo/vector/image and emit
193
+ * `Record<string, unknown>` under the *declared* name, so the augmentation claimed a column
194
+ * named `speaker` holding an object while Postgres and the engine both had `speaker_id`
195
+ * holding text. Wrong name and wrong type on the same column.
196
+ */
197
+ it("emits the foreign key column, not the declared relation name", () => {
198
+ const out = generateClientAugmentation(talk)
199
+ expect(out).toContain("speaker_id: string | null")
200
+ expect(out).not.toContain("speaker: Record<string, unknown>")
201
+ })
202
+
203
+ it("keeps a required relation non-nullable, matching the engine", () => {
204
+ expect(generateClientAugmentation(talk)).toContain("room_id: string\n")
205
+ })
206
+
207
+ /** No `foreignKey` annotation, so the column name falls back to the same convention. */
208
+ it("derives the column name when the AST carries no foreignKey", () => {
209
+ expect(generateClientAugmentation(talk)).toContain("auth_user_id: string | null")
210
+ })
211
+
212
+ /** The key lives on the other table, so this side has no column to describe. */
213
+ it("omits hasMany, which is not a column on this table", () => {
214
+ const out = generateClientAugmentation(talk)
215
+ expect(out).not.toContain("slots")
216
+ })
217
+ })
218
+
219
+ describe("column shapes that disagreed with the database", () => {
220
+ it("types a localized column as a locale map", () => {
221
+ // `page.title` is jsonb holding {"en": "...", "fr": "..."} and this said `string`, so
222
+ // `page.title.toUpperCase()` compiled and then failed in the browser.
223
+ const row = generateRowType({
224
+ title: { kind: "text", required: true, localized: true },
225
+ })
226
+ expect(row).toContain("title: { [locale: string]: string }")
227
+ })
228
+
229
+ it("leaves a non-localized column of the same kind alone", () => {
230
+ // The control: a fix that wrapped every text column would pass the assertion above.
231
+ const row = generateRowType({ slug: { kind: "text", required: true } })
232
+ expect(row).toContain("slug: string")
233
+ expect(row).not.toContain("locale")
234
+ })
235
+
236
+ it("types an image as what storage.upload actually produces", () => {
237
+ // Was `Record<string, unknown>`, which is why the examples cast on the good path.
238
+ const row = generateRowType({
239
+ headshot: { kind: "image", required: false, bucket: "speaker-headshots" },
240
+ })
241
+ expect(row).toContain("headshot: { bucket: string; path: string } | null")
242
+ })
243
+
244
+ it("types a file the same way", () => {
245
+ const row = generateRowType({ attachment: { kind: "file", required: true } })
246
+ expect(row).toContain("attachment: { bucket: string; path: string }")
247
+ })
248
+
249
+ it("does not store a url, because a stored one goes stale", () => {
250
+ const row = generateRowType({ headshot: { kind: "image", required: true } })
251
+ expect(row).not.toContain("url")
252
+ })
253
+ })