@supatype/cli 0.2.0 → 0.3.0

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 (172) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +190 -153
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/api-config-cache.d.ts +34 -0
  5. package/dist/api-config-cache.d.ts.map +1 -0
  6. package/dist/api-config-cache.js +123 -0
  7. package/dist/api-config-cache.js.map +1 -0
  8. package/dist/cache-identity-scope.d.ts +38 -0
  9. package/dist/cache-identity-scope.d.ts.map +1 -0
  10. package/dist/cache-identity-scope.js +70 -0
  11. package/dist/cache-identity-scope.js.map +1 -0
  12. package/dist/cache-provider.d.ts +30 -0
  13. package/dist/cache-provider.d.ts.map +1 -0
  14. package/dist/cache-provider.js +60 -0
  15. package/dist/cache-provider.js.map +1 -0
  16. package/dist/cli-version-embedded.js +1 -1
  17. package/dist/commands/add.js +1 -1
  18. package/dist/commands/add.js.map +1 -1
  19. package/dist/commands/cache.d.ts +1 -1
  20. package/dist/commands/cache.js +3 -3
  21. package/dist/commands/cache.js.map +1 -1
  22. package/dist/commands/cloud.d.ts.map +1 -1
  23. package/dist/commands/cloud.js +2 -1
  24. package/dist/commands/cloud.js.map +1 -1
  25. package/dist/commands/deploy.d.ts.map +1 -1
  26. package/dist/commands/deploy.js +2 -1
  27. package/dist/commands/deploy.js.map +1 -1
  28. package/dist/commands/dev.d.ts.map +1 -1
  29. package/dist/commands/dev.js +61 -8
  30. package/dist/commands/dev.js.map +1 -1
  31. package/dist/commands/diff.d.ts.map +1 -1
  32. package/dist/commands/diff.js +2 -1
  33. package/dist/commands/diff.js.map +1 -1
  34. package/dist/commands/functions.d.ts.map +1 -1
  35. package/dist/commands/functions.js +6 -2
  36. package/dist/commands/functions.js.map +1 -1
  37. package/dist/commands/init.d.ts.map +1 -1
  38. package/dist/commands/init.js +13 -9
  39. package/dist/commands/init.js.map +1 -1
  40. package/dist/commands/keys.d.ts +14 -0
  41. package/dist/commands/keys.d.ts.map +1 -1
  42. package/dist/commands/keys.js +37 -0
  43. package/dist/commands/keys.js.map +1 -1
  44. package/dist/commands/migrate.d.ts.map +1 -1
  45. package/dist/commands/migrate.js +2 -1
  46. package/dist/commands/migrate.js.map +1 -1
  47. package/dist/commands/push.d.ts.map +1 -1
  48. package/dist/commands/push.js +52 -1
  49. package/dist/commands/push.js.map +1 -1
  50. package/dist/commands/self-host.d.ts.map +1 -1
  51. package/dist/commands/self-host.js +9 -0
  52. package/dist/commands/self-host.js.map +1 -1
  53. package/dist/compose-services.d.ts.map +1 -1
  54. package/dist/compose-services.js +2 -1
  55. package/dist/compose-services.js.map +1 -1
  56. package/dist/dev-compose.d.ts.map +1 -1
  57. package/dist/dev-compose.js +18 -7
  58. package/dist/dev-compose.js.map +1 -1
  59. package/dist/engine-floor.d.ts +11 -1
  60. package/dist/engine-floor.d.ts.map +1 -1
  61. package/dist/engine-floor.js +49 -16
  62. package/dist/engine-floor.js.map +1 -1
  63. package/dist/functions-deno-types.d.ts +9 -0
  64. package/dist/functions-deno-types.d.ts.map +1 -1
  65. package/dist/functions-deno-types.js +45 -1
  66. package/dist/functions-deno-types.js.map +1 -1
  67. package/dist/functions-router-gen.d.ts.map +1 -1
  68. package/dist/functions-router-gen.js +113 -81
  69. package/dist/functions-router-gen.js.map +1 -1
  70. package/dist/kong-config.d.ts +1 -1
  71. package/dist/model-cache.d.ts +17 -0
  72. package/dist/model-cache.d.ts.map +1 -0
  73. package/dist/model-cache.js +45 -0
  74. package/dist/model-cache.js.map +1 -0
  75. package/dist/model-hooks.d.ts +5 -0
  76. package/dist/model-hooks.d.ts.map +1 -1
  77. package/dist/model-hooks.js +29 -1
  78. package/dist/model-hooks.js.map +1 -1
  79. package/dist/model-versioning.d.ts +80 -0
  80. package/dist/model-versioning.d.ts.map +1 -0
  81. package/dist/model-versioning.js +140 -0
  82. package/dist/model-versioning.js.map +1 -0
  83. package/dist/postgres-ctl.d.ts +55 -0
  84. package/dist/postgres-ctl.d.ts.map +1 -1
  85. package/dist/postgres-ctl.js +84 -8
  86. package/dist/postgres-ctl.js.map +1 -1
  87. package/dist/preview-config-check.d.ts +21 -0
  88. package/dist/preview-config-check.d.ts.map +1 -0
  89. package/dist/preview-config-check.js +47 -0
  90. package/dist/preview-config-check.js.map +1 -0
  91. package/dist/project-config.d.ts +182 -5
  92. package/dist/project-config.d.ts.map +1 -1
  93. package/dist/project-config.js +125 -8
  94. package/dist/project-config.js.map +1 -1
  95. package/dist/resolve-target.d.ts +9 -0
  96. package/dist/resolve-target.d.ts.map +1 -1
  97. package/dist/resolve-target.js.map +1 -1
  98. package/dist/rest-cache-admin.d.ts.map +1 -1
  99. package/dist/rest-cache-admin.js +6 -2
  100. package/dist/rest-cache-admin.js.map +1 -1
  101. package/dist/schema-ast-v2.d.ts +32 -1
  102. package/dist/schema-ast-v2.d.ts.map +1 -1
  103. package/dist/schema-ast-v2.js +14 -1
  104. package/dist/schema-ast-v2.js.map +1 -1
  105. package/dist/self-host-compose.d.ts +20 -1
  106. package/dist/self-host-compose.d.ts.map +1 -1
  107. package/dist/self-host-compose.js +181 -27
  108. package/dist/self-host-compose.js.map +1 -1
  109. package/dist/studio-admin-roles.d.ts +8 -1
  110. package/dist/studio-admin-roles.d.ts.map +1 -1
  111. package/dist/studio-admin-roles.js +16 -2
  112. package/dist/studio-admin-roles.js.map +1 -1
  113. package/dist/type-extractor.d.ts +7 -0
  114. package/dist/type-extractor.d.ts.map +1 -1
  115. package/dist/type-extractor.js +234 -3
  116. package/dist/type-extractor.js.map +1 -1
  117. package/package.json +2 -2
  118. package/src/api-config-cache.ts +142 -0
  119. package/src/cache-identity-scope.ts +92 -0
  120. package/src/cache-provider.ts +61 -0
  121. package/src/cli-version-embedded.ts +1 -1
  122. package/src/commands/add.ts +1 -1
  123. package/src/commands/cache.ts +3 -3
  124. package/src/commands/cloud.ts +2 -1
  125. package/src/commands/deploy.ts +2 -1
  126. package/src/commands/dev.ts +68 -7
  127. package/src/commands/diff.ts +2 -1
  128. package/src/commands/functions.ts +6 -2
  129. package/src/commands/init.ts +13 -9
  130. package/src/commands/keys.ts +40 -0
  131. package/src/commands/migrate.ts +2 -1
  132. package/src/commands/push.ts +57 -1
  133. package/src/commands/self-host.ts +9 -0
  134. package/src/compose-services.ts +2 -1
  135. package/src/dev-compose.ts +18 -7
  136. package/src/engine-floor.ts +73 -16
  137. package/src/functions-deno-types.ts +48 -1
  138. package/src/functions-router-gen.ts +113 -81
  139. package/src/kong-config.ts +1 -1
  140. package/src/model-cache.ts +74 -0
  141. package/src/model-hooks.ts +26 -2
  142. package/src/model-versioning.ts +167 -0
  143. package/src/postgres-ctl.ts +103 -9
  144. package/src/preview-config-check.ts +52 -0
  145. package/src/project-config.ts +273 -11
  146. package/src/resolve-target.ts +11 -1
  147. package/src/rest-cache-admin.ts +8 -2
  148. package/src/schema-ast-v2.ts +47 -0
  149. package/src/self-host-compose.ts +204 -27
  150. package/src/studio-admin-roles.ts +16 -2
  151. package/src/type-extractor.ts +281 -2
  152. package/tests/api-config-cache-seed.test.ts +180 -0
  153. package/tests/cache-identity-scope.test.ts +61 -0
  154. package/tests/cache-provider-config.test.ts +132 -0
  155. package/tests/engine-floor.test.ts +43 -0
  156. package/tests/external-database-compose.test.ts +74 -3
  157. package/tests/field-masking-tier.test.ts +3 -3
  158. package/tests/fixtures/identity-scope-corpus.json +42 -0
  159. package/tests/functions-concurrency.test.ts +71 -0
  160. package/tests/init.test.ts +36 -2
  161. package/tests/model-cache-declaration.test.ts +229 -0
  162. package/tests/model-cache-manifest.test.ts +99 -0
  163. package/tests/model-hooks.test.ts +55 -0
  164. package/tests/model-versions.test.ts +229 -0
  165. package/tests/native-keyspace.test.ts +130 -0
  166. package/tests/preview-config-check.test.ts +104 -0
  167. package/tests/rest-cache-admin.test.ts +36 -0
  168. package/tests/runtime-contract.test.ts +176 -22
  169. package/tests/self-host-keys.test.ts +92 -0
  170. package/tests/studio-admin-roles.test.ts +32 -0
  171. package/tests/type-extractor.test.ts +113 -0
  172. package/tsconfig.tsbuildinfo +1 -1
@@ -100,6 +100,44 @@ export interface SupatypeProjectConfig {
100
100
  provider?: "kong" | "none"
101
101
  }
102
102
  }
103
+ /**
104
+ * Where the REST response cache and Kong's ACME certificates live.
105
+ *
106
+ * Both speak RESP, so this is a choice of server rather than of feature.
107
+ */
108
+ cache?: {
109
+ /**
110
+ * "pg_keyspace" (default) = the RESP keyspace inside the Postgres
111
+ * container, so the stack runs one stateful
112
+ * service instead of two.
113
+ * "valkey" = a Valkey sidecar container.
114
+ *
115
+ * pg_keyspace needs `shared_preload_libraries`, which is a property of the
116
+ * Postgres this stack starts — so it is only available when Supatype
117
+ * provisions the database. With `database.external` the default resolves
118
+ * to Valkey, because there is no container to configure; asking for
119
+ * pg_keyspace explicitly there is rejected rather than ignored.
120
+ *
121
+ * Set "valkey" to keep the sidecar. Nothing about the cache's behaviour
122
+ * changes with the answer — both speak RESP and both hold the same keys.
123
+ */
124
+ provider?: "valkey" | "pg_keyspace"
125
+ /**
126
+ * Extra key prefixes to persist when `provider` is "pg_keyspace".
127
+ *
128
+ * The keyspace is a cache: everything is ephemeral, living in shared
129
+ * memory and not surviving a restart of the database. The exception is
130
+ * Kong's ACME certificates, which are always kept — you do not have to
131
+ * name them, and re-issuing them on every restart would meet Let's
132
+ * Encrypt's rate limits.
133
+ *
134
+ * List a prefix here only for something you are storing yourself that
135
+ * must outlive a restart. Each durable write goes through the WAL, which
136
+ * is the cost being avoided everywhere else. Each entry is a prefix of the
137
+ * key as stored — see `pg_keyspace.durability_overrides`.
138
+ */
139
+ durablePrefixes?: string[]
140
+ }
103
141
  app: {
104
142
  /**
105
143
  * How the root path "/" is handled by supatype-server.
@@ -225,6 +263,54 @@ export interface SupatypeProjectConfig {
225
263
  */
226
264
  api_schemas?: readonly string[]
227
265
  }
266
+ /**
267
+ * Drafts and preview links, for the models that declare `versions`.
268
+ *
269
+ * Project-wide rather than per model: a draft is a draft whichever model it belongs to, and a
270
+ * reviewer granted sight of one is not granted it a table at a time.
271
+ */
272
+ publishing?: {
273
+ /**
274
+ * Studio roles that see every unpublished draft and its history, beyond the record's own
275
+ * creator. Defaults to all three, `["admin", "developer", "editor"]`.
276
+ *
277
+ * The record's creator always sees their own drafts and is not listed here. Narrowing this is
278
+ * the interesting case, and it has a trap: whoever may **update** a record may draft and publish
279
+ * it, so a role that can edit but cannot see drafts will build its save on the live row and
280
+ * discard a colleague's pending work. Take a role off this list only where it also cannot edit.
281
+ *
282
+ * An empty list narrows visibility to creators alone.
283
+ *
284
+ * `anon` is never on this list and cannot be put on it. A draft schema readable without
285
+ * credentials would make publishing mean nothing.
286
+ *
287
+ * Studio can override this at runtime for a project that needs to change it without a push.
288
+ * This is the reviewable default that lives in git.
289
+ */
290
+ draft_visibility?: readonly ("admin" | "developer" | "editor")[]
291
+ /** Signed, expiring links that let someone without an account read a draft. */
292
+ preview?: {
293
+ /** Lifetime of a minted link when the request does not ask for one, in seconds. Default 900. */
294
+ default_ttl?: number
295
+ /** Ceiling for a link covering one record, in seconds. Default 604800, seven days. */
296
+ max_record_ttl?: number
297
+ /**
298
+ * Ceiling for a link covering every draft in the project, in seconds. Default 86400, one day.
299
+ *
300
+ * Shorter than a record link on purpose: the blast radius is every unpublished draft there is,
301
+ * so the window in which a leaked link is still worth anything should be smaller.
302
+ */
303
+ max_project_ttl?: number
304
+ /**
305
+ * Whether project-scoped links may be minted at all. Default true.
306
+ *
307
+ * Set false for a project where nothing should ever be shareable in bulk; record links keep
308
+ * working. Minting one is already restricted to `admin` and `developer`, since nobody can
309
+ * grant sight of drafts they cannot see themselves.
310
+ */
311
+ allow_project_scope?: boolean
312
+ }
313
+ }
228
314
  functions?: {
229
315
  /** Path to edge functions directory, relative to `supatype.root` when not absolute. */
230
316
  path?: string
@@ -286,6 +372,37 @@ export interface SupatypeProjectConfig {
286
372
  admin?: {
287
373
  /** JWT `app_metadata.role` values allowed to use Studio. Default: admin, supatype_admin */
288
374
  roles?: string[]
375
+ /**
376
+ * Where each model renders in your own front end, keyed by model name.
377
+ *
378
+ * Studio needs this for two things that look alike and are not: the live preview pane, which
379
+ * streams unsaved keystrokes into an iframe, and a shared preview link, which points somebody
380
+ * with no account at a saved draft. Both need the same answer to "what is the address of this
381
+ * record", and neither can guess it.
382
+ *
383
+ * Without it there is no share link to give out. Studio says so rather than handing over the
384
+ * bare code, because a credential with nowhere to open it reads as a bug.
385
+ *
386
+ * `urlPattern` names fields in braces and they are filled from the record in hand, so a post
387
+ * previews at the address its current slug implies.
388
+ *
389
+ * **A path is enough when this deployment serves the app.** With `app.mode` set to `static` or
390
+ * `proxy`, the app is served at `/` on the same origin as the API, and Studio resolves a path
391
+ * against the origin it is loaded from. Writing the origin out again only risks it going stale:
392
+ *
393
+ * ```ts
394
+ * admin: { livePreview: { Post: { urlPattern: "/preview/{slug}" } } }
395
+ * ```
396
+ *
397
+ * With `app.mode: "none"` the app is somewhere else and no deployment fact can say where, so
398
+ * give an absolute URL. `supatype push` refuses a path-only pattern in that mode rather than
399
+ * letting it become a link that resolves to nothing.
400
+ *
401
+ * ```ts
402
+ * admin: { livePreview: { Post: { urlPattern: "https://example.com/preview/{slug}" } } }
403
+ * ```
404
+ */
405
+ livePreview?: Record<string, { url?: string; urlPattern?: string }>
289
406
  }
290
407
  }
291
408
 
@@ -407,10 +524,54 @@ export function validateProjectConfig(raw: unknown, filename: string): SupatypeP
407
524
  }
408
525
 
409
526
  validateExternalDatabase(cfg, filename)
527
+ validateCache(cfg, filename)
410
528
 
411
529
  return raw as SupatypeProjectConfig
412
530
  }
413
531
 
532
+ /**
533
+ * `cache.provider = "pg_keyspace"` describes how the Postgres this stack
534
+ * starts is configured, so it cannot be honoured against a database Supatype
535
+ * does not manage. Rejected rather than silently falling back to Valkey: a
536
+ * stack that quietly runs the thing you switched away from is worse than one
537
+ * that will not start.
538
+ */
539
+ function validateCache(cfg: Record<string, unknown>, filename: string): void {
540
+ const cache = cfg["cache"] as Record<string, unknown> | undefined
541
+ if (!cache) return
542
+ const provider = cache["provider"]
543
+ if (provider !== undefined && provider !== "valkey" && provider !== "pg_keyspace") {
544
+ throw new Error(
545
+ `${filename}: cache.provider must be "valkey" or "pg_keyspace" (got ${JSON.stringify(provider)})`,
546
+ )
547
+ }
548
+ const database = cfg["database"] as Record<string, unknown> | undefined
549
+ if (provider === "pg_keyspace" && database?.["external"]) {
550
+ throw new Error(
551
+ `${filename}: cache.provider = "pg_keyspace" needs the Postgres this stack starts — ` +
552
+ `it is loaded with shared_preload_libraries, which is not something Supatype can set on ` +
553
+ `a database.external one. Use cache.provider = "valkey", or drop database.external.`,
554
+ )
555
+ }
556
+ const prefixes = cache["durablePrefixes"]
557
+ if (prefixes !== undefined) {
558
+ if (!Array.isArray(prefixes) || prefixes.some((p) => typeof p !== "string" || p.length === 0)) {
559
+ throw new Error(`${filename}: cache.durablePrefixes must be an array of non-empty strings`)
560
+ }
561
+ // `,` separates entries and `=` separates a prefix from its tier in
562
+ // pg_keyspace.durability_overrides, so a prefix containing either would be
563
+ // read as two settings. Refused here rather than mangled into the command
564
+ // line, where it would surface as a Postgres that will not start.
565
+ const bad = (prefixes as string[]).find((p) => p.includes(",") || p.includes("="))
566
+ if (bad) {
567
+ throw new Error(
568
+ `${filename}: cache.durablePrefixes entry ${JSON.stringify(bad)} cannot contain "," or "=" — ` +
569
+ `both are separators in pg_keyspace.durability_overrides`,
570
+ )
571
+ }
572
+ }
573
+ }
574
+
414
575
  /**
415
576
  * Rules for `database.external`, all of them errors rather than precedence.
416
577
  *
@@ -635,6 +796,22 @@ export function pgSchema(cfg: SupatypeProjectConfig): string {
635
796
  */
636
797
  export const STACK_API_SCHEMAS = ["supatype", "graphql_public", "auth"] as const
637
798
 
799
+ /**
800
+ * The schema holding generated draft views, one per versioned model.
801
+ *
802
+ * Its own schema rather than a suffix on the table name, so a draft has the same table name and the
803
+ * same row type as what it is a draft of, and a client reaches it by switching profile.
804
+ */
805
+ export const DRAFT_SCHEMA = "draft"
806
+
807
+ /** What shapes the exposed-schema list, beyond the config itself. */
808
+ export type ApiSchemaOptions = {
809
+ /** How field rules will be enforced here. `views` moves the managed schema off the list. */
810
+ tier?: "none" | "extension" | "views" | undefined
811
+ /** True when any model declares `versions`, which is what puts the `draft` schema on the list. */
812
+ drafts?: boolean | undefined
813
+ }
814
+
638
815
  /**
639
816
  * Schemas to expose over REST, as `PGRST_DB_SCHEMA` wants them.
640
817
  *
@@ -644,18 +821,24 @@ export const STACK_API_SCHEMAS = ["supatype", "graphql_public", "auth"] as const
644
821
  * output mentioned the setting that caused it.
645
822
  *
646
823
  * `api_schemas` replaces the whole list when stated, including the stack schemas, so dropping
647
- * `supatype` from it is a supported way to stop exposing Studio's views. Order is preserved and
648
- * duplicates removed: PostgREST serves the first entry as the default profile, so the managed
649
- * schema has to lead.
824
+ * `supatype` from it is a supported way to stop exposing Studio's views. That holds for `draft` too:
825
+ * an explicit list has to name it, and `db check` reports a project whose models declare `versions`
826
+ * against a list that does not. Order is preserved and duplicates removed: PostgREST serves the first
827
+ * entry as the default profile, so the managed schema has to lead.
650
828
  */
651
- export function apiSchemas(cfg: SupatypeProjectConfig, tier?: "none" | "extension" | "views"): string[] {
829
+ export function apiSchemas(cfg: SupatypeProjectConfig, options?: ApiSchemaOptions): string[] {
652
830
  const explicit = cfg.schema?.api_schemas
653
831
  // Tier-2 field masking serves from `api`, and the managed schema must come **off** the list: a
654
832
  // client picks its schema per request with `Accept-Profile`, so leaving it exposed would let any
655
833
  // caller read the unmasked table and make the mask opt-out. The API roles hold no privileges there
656
834
  // under tier 2 either, so exposing it would only produce denials.
657
- const managed = tier === "views" ? "api" : pgSchema(cfg)
658
- const list = explicit && explicit.length > 0 ? explicit : [managed, ...STACK_API_SCHEMAS]
835
+ const managed = options?.tier === "views" ? "api" : pgSchema(cfg)
836
+ // `draft` sits behind the managed schema and never in front of it: the first entry is the default
837
+ // profile, and a client that named no profile reading drafts by default would invert the feature.
838
+ const derived = options?.drafts === true
839
+ ? [managed, DRAFT_SCHEMA, ...STACK_API_SCHEMAS]
840
+ : [managed, ...STACK_API_SCHEMAS]
841
+ const list = explicit && explicit.length > 0 ? explicit : derived
659
842
 
660
843
  const seen = new Set<string>()
661
844
  const out: string[] = []
@@ -669,9 +852,88 @@ export function apiSchemas(cfg: SupatypeProjectConfig, tier?: "none" | "extensio
669
852
  }
670
853
 
671
854
  /** `PGRST_DB_SCHEMA` value: comma-separated, in order. */
672
- export function apiSchemaList(
673
- cfg: SupatypeProjectConfig,
674
- tier?: "none" | "extension" | "views",
675
- ): string {
676
- return apiSchemas(cfg, tier).join(", ")
855
+ export function apiSchemaList(cfg: SupatypeProjectConfig, options?: ApiSchemaOptions): string {
856
+ return apiSchemas(cfg, options).join(", ")
857
+ }
858
+
859
+ /**
860
+ * Studio roles that see every draft, beyond each record's own creator.
861
+ *
862
+ * All three of them, which is to say anyone with Studio write access. It was narrower, `admin` and
863
+ * `developer` only, and that put two decisions in conflict: whoever may **update** a record may
864
+ * draft and publish it, so an `editor` could edit a record whose pending draft they could not see,
865
+ * and their save would be built on the live row and silently discard a colleague's work. Two editors
866
+ * on one post is the ordinary editorial case, not an exotic one. Seeing and acting line up instead.
867
+ *
868
+ * The creator is not in this list and cannot be removed from it: they wrote the draft, and a system
869
+ * where you cannot read back what you just saved is broken rather than secure. An empty configured
870
+ * list is honoured and means creators only, which is why this cannot fall back on emptiness.
871
+ *
872
+ * `anon` is not a Studio role and can never be here. A draft readable without credentials would make
873
+ * publishing mean nothing.
874
+ */
875
+ export const DEFAULT_DRAFT_VISIBILITY = ["admin", "developer", "editor"] as const
876
+
877
+ /** Studio roles a Supatype project understands, most privileged first. */
878
+ export const STUDIO_ROLES = ["admin", "developer", "editor"] as const
879
+
880
+ /**
881
+ * The configured draft-visibility roles, or the default when the project states none.
882
+ *
883
+ * An unknown role name is dropped rather than passed through to a policy, where it would be a role
884
+ * nothing can ever hold: a typo would then read as a working setting that silently grants nobody.
885
+ */
886
+ export function draftVisibilityRoles(cfg: SupatypeProjectConfig): string[] {
887
+ const declared = cfg.publishing?.draft_visibility
888
+ if (declared === undefined) return [...DEFAULT_DRAFT_VISIBILITY]
889
+ const known = new Set<string>(STUDIO_ROLES)
890
+ const seen = new Set<string>()
891
+ const out: string[] = []
892
+ for (const raw of declared) {
893
+ const role = raw.trim().toLowerCase()
894
+ if (!known.has(role) || seen.has(role)) continue
895
+ seen.add(role)
896
+ out.push(role)
897
+ }
898
+ return out
899
+ }
900
+
901
+ /** Bounds on a minted preview link, in seconds. */
902
+ export type PreviewLimits = {
903
+ defaultTtl: number
904
+ maxRecordTtl: number
905
+ maxProjectTtl: number
906
+ allowProjectScope: boolean
907
+ }
908
+
909
+ export const PREVIEW_DEFAULTS: PreviewLimits = {
910
+ defaultTtl: 900,
911
+ maxRecordTtl: 604800,
912
+ maxProjectTtl: 86400,
913
+ allowProjectScope: true,
914
+ }
915
+
916
+ /**
917
+ * The project's preview-link bounds, defaults filled in.
918
+ *
919
+ * A stated value is clamped to at least a second and, for the default lifetime, to no more than the
920
+ * ceiling it would be issued against: a `default_ttl` above `max_record_ttl` would otherwise mint
921
+ * links that the same config refuses to honour.
922
+ */
923
+ export function previewLimits(cfg: SupatypeProjectConfig): PreviewLimits {
924
+ const declared = cfg.publishing?.preview
925
+ const positive = (value: number | undefined, fallback: number): number =>
926
+ typeof value === "number" && Number.isFinite(value) && value >= 1 ? Math.floor(value) : fallback
927
+
928
+ const maxRecordTtl = positive(declared?.max_record_ttl, PREVIEW_DEFAULTS.maxRecordTtl)
929
+ const maxProjectTtl = positive(declared?.max_project_ttl, PREVIEW_DEFAULTS.maxProjectTtl)
930
+ return {
931
+ defaultTtl: Math.min(
932
+ positive(declared?.default_ttl, PREVIEW_DEFAULTS.defaultTtl),
933
+ Math.max(maxRecordTtl, maxProjectTtl),
934
+ ),
935
+ maxRecordTtl,
936
+ maxProjectTtl,
937
+ allowProjectScope: declared?.allow_project_scope ?? PREVIEW_DEFAULTS.allowProjectScope,
938
+ }
677
939
  }
@@ -265,7 +265,17 @@ export async function targetSchemaPush(
265
265
  target: DeployTarget,
266
266
  ast: unknown,
267
267
  opts?: { force?: boolean; schema?: string; schemaSources?: SchemaSourcesPayload | null },
268
- ): Promise<{ message?: string; status?: string; name?: string }> {
268
+ ): Promise<{
269
+ message?: string
270
+ status?: string
271
+ name?: string
272
+ /**
273
+ * What the control plane made of this schema's cache declaration. Absent from an engine push and
274
+ * from a control plane too old to send it, which is why every reader of it has to treat absence
275
+ * as "nothing to say" rather than as an empty declaration.
276
+ */
277
+ cache?: { tables?: string[]; honoured?: boolean }
278
+ }> {
269
279
  if (target.mode === "direct" || (target.mode === "local" && !target.token)) {
270
280
  await ensureEngine()
271
281
  const body: Record<string, unknown> = {
@@ -65,8 +65,14 @@ function formatCacheApiError(
65
65
  if (status === 403 && trimmed.includes("rest_cache_not_available")) {
66
66
  return "REST server cache is not available on this plan or deployment."
67
67
  }
68
- if (status === 503 && trimmed.includes("valkey")) {
69
- return "Valkey is not configured, server-side REST cache requires Valkey (supatype dev with docker)."
68
+ // Both spellings: the server answers "keyspace not configured" now and
69
+ // "valkey not configured" before the rename, and a CLI is routinely newer
70
+ // than the deployment it is pointed at.
71
+ if (status === 503 && (trimmed.includes("keyspace") || trimmed.includes("valkey"))) {
72
+ return (
73
+ "No cache server is configured — server-side REST caching needs one. " +
74
+ "`supatype dev` starts it for you; on a self-hosted stack, check the `db` or `valkey` service."
75
+ )
70
76
  }
71
77
  return `${operation} failed (${status}): ${trimmed || "(empty body)"}`
72
78
  }
@@ -95,6 +95,8 @@ export interface DbFieldAnnotations {
95
95
  export interface PlatformFieldAnnotations {
96
96
  editor?: string
97
97
  readOnly?: boolean
98
+ /** Studio searches this column in the list view. Set by `Searchable<T>`. */
99
+ searchable?: boolean
98
100
  }
99
101
 
100
102
  export interface FieldAnnotations {
@@ -143,6 +145,18 @@ export interface KernelFieldFacts {
143
145
  index?: boolean
144
146
  }
145
147
 
148
+ /**
149
+ * The `cache` declaration as it reaches the AST. See `ModelMeta.cache` in `@supatype/types` for
150
+ * what each setting means; this is the wire shape, kept narrow so a typo in the extractor is a
151
+ * type error rather than an extra key nobody reads.
152
+ */
153
+ export interface ModelCacheAst {
154
+ enabled?: boolean
155
+ maxTtl?: number
156
+ public?: boolean
157
+ rows?: boolean
158
+ }
159
+
146
160
  /** Internal parse result: not serialized. */
147
161
  export interface ParsedField {
148
162
  kind: FieldKind
@@ -173,6 +187,9 @@ export interface ModelAstV2 {
173
187
  access: Record<string, unknown>
174
188
  hooks?: Record<string, unknown>
175
189
  validate?: Record<string, unknown>
190
+ /** Columns Studio's list view searches. See {@link emitModel}. */
191
+ searchFields?: string[]
192
+ cache?: ModelCacheAst
176
193
  }
177
194
  }
178
195
  }
@@ -193,6 +210,21 @@ export interface ExtractedSchemaAstV2 {
193
210
  storageBuckets?: ExtractedStorageBucketAst[]
194
211
  locales?: string[]
195
212
  defaultLocale?: string
213
+ /**
214
+ * Draft visibility and preview-link bounds, from `supatype.config.ts`.
215
+ *
216
+ * Not extracted from the schema like everything else here: it is a project setting that the
217
+ * engine needs at the moment it compiles the draft policies, so it rides the same payload rather
218
+ * than growing a delivery path of its own. Attached by `withPublishing`, and absent for a project
219
+ * with no versioned model.
220
+ */
221
+ publishing?: {
222
+ draftVisibility: string[]
223
+ previewDefaultTtl: number
224
+ previewMaxRecordTtl: number
225
+ previewMaxProjectTtl: number
226
+ previewAllowProjectScope: boolean
227
+ }
196
228
  }
197
229
 
198
230
  const DEFAULT_DB_BY_KIND: Partial<Record<FieldKind, Partial<DbFieldAnnotations>>> = {
@@ -391,6 +423,8 @@ export function emitModel(
391
423
  hooks: Record<string, unknown> = {},
392
424
  constraints: unknown[] = [],
393
425
  validators: Record<string, unknown> = {},
426
+ searchFields: string[] = [],
427
+ cache?: ModelCacheAst | undefined,
394
428
  ): ModelAstV2 {
395
429
  return {
396
430
  name,
@@ -404,10 +438,23 @@ export function emitModel(
404
438
  // supatype-server reads them, Postgres never sees them.
405
439
  // Validators sit in `platform` beside `hooks`: both are enforced by the API layer on the
406
440
  // write path, and neither is something Postgres knows about.
441
+ // `searchFields` sits in `platform` because it is an admin-UI concern: Studio's list view
442
+ // renders its search box when a model has them and filters on the first. Postgres knows
443
+ // nothing about it, which is why it is not in `db` beside `indexes` — searching a column and
444
+ // indexing one are different asks, and `Indexed` already covers the latter.
407
445
  platform: {
408
446
  access,
409
447
  ...(Object.keys(hooks).length > 0 && { hooks }),
410
448
  ...(Object.keys(validators).length > 0 && { validate: validators }),
449
+ ...(searchFields.length > 0 && { searchFields }),
450
+ // Cache sits in `platform` beside `hooks` for the same reason, and travels the same way:
451
+ // the route manifest, not this AST. The schema engine re-serialises its own parsed struct
452
+ // when it writes `ast_snapshot`, and its `PlatformModelAnnotations` knows only `access`
453
+ // and `searchFields` — so this key, like `hooks` above it, does not survive that round
454
+ // trip and the manifest is what the server actually reads. Kept here because the AST is
455
+ // the schema's own description of itself, and a reader looking for the declaration should
456
+ // find it next to the other API-layer concerns.
457
+ ...(cache !== undefined && { cache }),
411
458
  },
412
459
  },
413
460
  }