@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +161 -154
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/api-config-cache.d.ts +20 -0
- package/dist/api-config-cache.d.ts.map +1 -1
- package/dist/api-config-cache.js +27 -0
- package/dist/api-config-cache.js.map +1 -1
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +63 -18
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/client-generator.d.ts +3 -0
- package/dist/client-generator.d.ts.map +1 -0
- package/dist/client-generator.js +108 -0
- package/dist/client-generator.js.map +1 -0
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +21 -4
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +2 -2
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +7 -10
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/keys.d.ts +35 -0
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +90 -6
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +23 -13
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +19 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/db-port.d.ts +36 -0
- package/dist/db-port.d.ts.map +1 -0
- package/dist/db-port.js +90 -0
- package/dist/db-port.js.map +1 -0
- package/dist/dev-compose.d.ts +13 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +205 -18
- package/dist/dev-compose.js.map +1 -1
- package/dist/functions-context-refresh.d.ts +4 -0
- package/dist/functions-context-refresh.d.ts.map +1 -0
- package/dist/functions-context-refresh.js +26 -0
- package/dist/functions-context-refresh.js.map +1 -0
- package/dist/functions-deno-types.d.ts +9 -0
- package/dist/functions-deno-types.d.ts.map +1 -1
- package/dist/functions-deno-types.js +21 -1
- package/dist/functions-deno-types.js.map +1 -1
- package/dist/gitignore.d.ts +3 -1
- package/dist/gitignore.d.ts.map +1 -1
- package/dist/gitignore.js +28 -6
- package/dist/gitignore.js.map +1 -1
- package/dist/model-cache.d.ts +27 -0
- package/dist/model-cache.d.ts.map +1 -1
- package/dist/model-cache.js +61 -0
- package/dist/model-cache.js.map +1 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +17 -8
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/self-host-compose.d.ts +10 -2
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +114 -9
- package/dist/self-host-compose.js.map +1 -1
- package/dist/strict.d.ts +29 -0
- package/dist/strict.d.ts.map +1 -0
- package/dist/strict.js +37 -0
- package/dist/strict.js.map +1 -0
- package/dist/studio-dev-server.d.ts +12 -2
- package/dist/studio-dev-server.d.ts.map +1 -1
- package/dist/studio-dev-server.js +12 -2
- package/dist/studio-dev-server.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +8 -2
- package/dist/type-extractor.js.map +1 -1
- package/dist/type-generation.d.ts +14 -0
- package/dist/type-generation.d.ts.map +1 -1
- package/dist/type-generation.js +41 -2
- package/dist/type-generation.js.map +1 -1
- package/package.json +2 -2
- package/src/api-config-cache.ts +30 -0
- package/src/augmentation-generator.ts +75 -18
- package/src/cli-version-embedded.ts +1 -1
- package/src/client-generator.ts +122 -0
- package/src/commands/admin.ts +23 -6
- package/src/commands/functions.ts +5 -2
- package/src/commands/init.ts +10 -10
- package/src/commands/keys.ts +112 -7
- package/src/commands/push.ts +25 -15
- package/src/commands/update.ts +20 -1
- package/src/db-port.ts +99 -0
- package/src/dev-compose.ts +244 -19
- package/src/functions-context-refresh.ts +25 -0
- package/src/functions-deno-types.ts +24 -1
- package/src/gitignore.ts +31 -10
- package/src/model-cache.ts +48 -0
- package/src/schema-ast-v2.ts +17 -8
- package/src/self-host-compose.ts +115 -9
- package/src/strict.ts +40 -0
- package/src/studio-dev-server.ts +12 -2
- package/src/type-extractor.ts +8 -2
- package/src/type-generation.ts +47 -2
- package/tests/ast-derived-outputs.test.ts +92 -0
- package/tests/augmentation-generator.test.ts +127 -1
- package/tests/client-generator.test.ts +103 -0
- package/tests/db-port.test.ts +128 -0
- package/tests/external-database-compose.test.ts +8 -1
- package/tests/functions-context-refresh.test.ts +86 -0
- package/tests/gitignore-secrets.test.ts +90 -0
- package/tests/keys-write.test.ts +148 -0
- package/tests/runtime-contract.test.ts +115 -2
- package/tests/strict.test.ts +78 -0
- package/tests/type-extractor.test.ts +34 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/schema-ast-v2.ts
CHANGED
|
@@ -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: "
|
|
256
|
+
decimal: { pgType: "NUMERIC" },
|
|
248
257
|
bytes: { pgType: "BYTEA" },
|
|
249
258
|
serial: { pgType: "SERIAL" },
|
|
250
259
|
bigSerial: { pgType: "BIGSERIAL" },
|
|
251
|
-
money: { pgType: "
|
|
252
|
-
ip: { pgType: "
|
|
253
|
-
cidr: { pgType: "
|
|
254
|
-
macaddr: { pgType: "
|
|
255
|
-
xml: { pgType: "
|
|
256
|
-
tsQuery: { pgType: "
|
|
257
|
-
tsVector: { pgType: "
|
|
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" },
|
package/src/self-host-compose.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
+
}
|
package/src/studio-dev-server.ts
CHANGED
|
@@ -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
|
-
/**
|
|
6
|
-
|
|
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
|
package/src/type-extractor.ts
CHANGED
|
@@ -1055,7 +1055,10 @@ function parseScalarType(
|
|
|
1055
1055
|
const assetOpts = parseAssetFieldOptions(typeNode.typeArguments?.[1], sourceFile)
|
|
1056
1056
|
return attachStorageFieldMeta(
|
|
1057
1057
|
scalar("file", {
|
|
1058
|
-
|
|
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
|
-
|
|
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,
|
package/src/type-generation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|