@supatype/cli 0.2.1 → 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 (162) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +180 -143
  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/migrate.d.ts.map +1 -1
  41. package/dist/commands/migrate.js +2 -1
  42. package/dist/commands/migrate.js.map +1 -1
  43. package/dist/commands/push.d.ts.map +1 -1
  44. package/dist/commands/push.js +52 -1
  45. package/dist/commands/push.js.map +1 -1
  46. package/dist/compose-services.d.ts.map +1 -1
  47. package/dist/compose-services.js +2 -1
  48. package/dist/compose-services.js.map +1 -1
  49. package/dist/dev-compose.d.ts.map +1 -1
  50. package/dist/dev-compose.js +18 -7
  51. package/dist/dev-compose.js.map +1 -1
  52. package/dist/engine-floor.d.ts +11 -1
  53. package/dist/engine-floor.d.ts.map +1 -1
  54. package/dist/engine-floor.js +49 -16
  55. package/dist/engine-floor.js.map +1 -1
  56. package/dist/functions-deno-types.d.ts +9 -0
  57. package/dist/functions-deno-types.d.ts.map +1 -1
  58. package/dist/functions-deno-types.js +45 -1
  59. package/dist/functions-deno-types.js.map +1 -1
  60. package/dist/functions-router-gen.d.ts.map +1 -1
  61. package/dist/functions-router-gen.js +113 -81
  62. package/dist/functions-router-gen.js.map +1 -1
  63. package/dist/kong-config.d.ts +1 -1
  64. package/dist/model-cache.d.ts +17 -0
  65. package/dist/model-cache.d.ts.map +1 -0
  66. package/dist/model-cache.js +45 -0
  67. package/dist/model-cache.js.map +1 -0
  68. package/dist/model-hooks.d.ts +5 -0
  69. package/dist/model-hooks.d.ts.map +1 -1
  70. package/dist/model-hooks.js +29 -1
  71. package/dist/model-hooks.js.map +1 -1
  72. package/dist/model-versioning.d.ts +80 -0
  73. package/dist/model-versioning.d.ts.map +1 -0
  74. package/dist/model-versioning.js +140 -0
  75. package/dist/model-versioning.js.map +1 -0
  76. package/dist/postgres-ctl.d.ts +55 -0
  77. package/dist/postgres-ctl.d.ts.map +1 -1
  78. package/dist/postgres-ctl.js +84 -8
  79. package/dist/postgres-ctl.js.map +1 -1
  80. package/dist/preview-config-check.d.ts +21 -0
  81. package/dist/preview-config-check.d.ts.map +1 -0
  82. package/dist/preview-config-check.js +47 -0
  83. package/dist/preview-config-check.js.map +1 -0
  84. package/dist/project-config.d.ts +182 -5
  85. package/dist/project-config.d.ts.map +1 -1
  86. package/dist/project-config.js +125 -8
  87. package/dist/project-config.js.map +1 -1
  88. package/dist/resolve-target.d.ts +9 -0
  89. package/dist/resolve-target.d.ts.map +1 -1
  90. package/dist/resolve-target.js.map +1 -1
  91. package/dist/rest-cache-admin.d.ts.map +1 -1
  92. package/dist/rest-cache-admin.js +6 -2
  93. package/dist/rest-cache-admin.js.map +1 -1
  94. package/dist/schema-ast-v2.d.ts +32 -1
  95. package/dist/schema-ast-v2.d.ts.map +1 -1
  96. package/dist/schema-ast-v2.js +14 -1
  97. package/dist/schema-ast-v2.js.map +1 -1
  98. package/dist/self-host-compose.d.ts +20 -1
  99. package/dist/self-host-compose.d.ts.map +1 -1
  100. package/dist/self-host-compose.js +181 -27
  101. package/dist/self-host-compose.js.map +1 -1
  102. package/dist/studio-admin-roles.d.ts +8 -1
  103. package/dist/studio-admin-roles.d.ts.map +1 -1
  104. package/dist/studio-admin-roles.js +16 -2
  105. package/dist/studio-admin-roles.js.map +1 -1
  106. package/dist/type-extractor.d.ts +7 -0
  107. package/dist/type-extractor.d.ts.map +1 -1
  108. package/dist/type-extractor.js +234 -3
  109. package/dist/type-extractor.js.map +1 -1
  110. package/package.json +2 -2
  111. package/src/api-config-cache.ts +142 -0
  112. package/src/cache-identity-scope.ts +92 -0
  113. package/src/cache-provider.ts +61 -0
  114. package/src/cli-version-embedded.ts +1 -1
  115. package/src/commands/add.ts +1 -1
  116. package/src/commands/cache.ts +3 -3
  117. package/src/commands/cloud.ts +2 -1
  118. package/src/commands/deploy.ts +2 -1
  119. package/src/commands/dev.ts +68 -7
  120. package/src/commands/diff.ts +2 -1
  121. package/src/commands/functions.ts +6 -2
  122. package/src/commands/init.ts +13 -9
  123. package/src/commands/migrate.ts +2 -1
  124. package/src/commands/push.ts +57 -1
  125. package/src/compose-services.ts +2 -1
  126. package/src/dev-compose.ts +18 -7
  127. package/src/engine-floor.ts +73 -16
  128. package/src/functions-deno-types.ts +48 -1
  129. package/src/functions-router-gen.ts +113 -81
  130. package/src/kong-config.ts +1 -1
  131. package/src/model-cache.ts +74 -0
  132. package/src/model-hooks.ts +26 -2
  133. package/src/model-versioning.ts +167 -0
  134. package/src/postgres-ctl.ts +103 -9
  135. package/src/preview-config-check.ts +52 -0
  136. package/src/project-config.ts +273 -11
  137. package/src/resolve-target.ts +11 -1
  138. package/src/rest-cache-admin.ts +8 -2
  139. package/src/schema-ast-v2.ts +47 -0
  140. package/src/self-host-compose.ts +204 -27
  141. package/src/studio-admin-roles.ts +16 -2
  142. package/src/type-extractor.ts +281 -2
  143. package/tests/api-config-cache-seed.test.ts +180 -0
  144. package/tests/cache-identity-scope.test.ts +61 -0
  145. package/tests/cache-provider-config.test.ts +132 -0
  146. package/tests/engine-floor.test.ts +43 -0
  147. package/tests/external-database-compose.test.ts +74 -3
  148. package/tests/field-masking-tier.test.ts +3 -3
  149. package/tests/fixtures/identity-scope-corpus.json +42 -0
  150. package/tests/functions-concurrency.test.ts +71 -0
  151. package/tests/init.test.ts +36 -2
  152. package/tests/model-cache-declaration.test.ts +229 -0
  153. package/tests/model-cache-manifest.test.ts +99 -0
  154. package/tests/model-hooks.test.ts +55 -0
  155. package/tests/model-versions.test.ts +229 -0
  156. package/tests/native-keyspace.test.ts +130 -0
  157. package/tests/preview-config-check.test.ts +104 -0
  158. package/tests/rest-cache-admin.test.ts +36 -0
  159. package/tests/runtime-contract.test.ts +176 -22
  160. package/tests/studio-admin-roles.test.ts +32 -0
  161. package/tests/type-extractor.test.ts +113 -0
  162. package/tsconfig.tsbuildinfo +1 -1
@@ -11,6 +11,7 @@
11
11
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
12
12
  import { join, relative } from "node:path"
13
13
  import { generateHooksModule } from "./hooks-generator.js"
14
+ import { manifestCache } from "./model-cache.js"
14
15
 
15
16
  export interface DeclaredHook {
16
17
  model: string
@@ -25,6 +26,8 @@ export function declaredHooks(ast: unknown): DeclaredHook[] {
25
26
 
26
27
  const out: DeclaredHook[] = []
27
28
  for (const model of models) {
29
+ // Guarded before the optional chains, not by them: see manifestHooks.
30
+ if (typeof model !== "object" || model === null) continue
28
31
  const shaped = model as {
29
32
  name?: string
30
33
  annotations?: { platform?: { hooks?: Record<string, unknown> } }
@@ -132,6 +135,10 @@ export function manifestHooks(ast: unknown): Record<string, Record<string, Manif
132
135
 
133
136
  const out: Record<string, Record<string, ManifestHookEntry>> = {}
134
137
  for (const model of models) {
138
+ // Guarded before the optional chains, not by them: `x?.y` protects against `x.y` being
139
+ // nullish, not against `x` itself being null, so a malformed entry in the models array would
140
+ // throw a TypeError out of `supatype push` rather than being skipped.
141
+ if (typeof model !== "object" || model === null) continue
135
142
  const shaped = model as {
136
143
  annotations?: {
137
144
  db?: { tableName?: string }
@@ -179,6 +186,8 @@ export function declaredValidators(ast: unknown): DeclaredValidator[] {
179
186
 
180
187
  const out: DeclaredValidator[] = []
181
188
  for (const model of models) {
189
+ // Guarded before the optional chains, not by them: see manifestHooks.
190
+ if (typeof model !== "object" || model === null) continue
182
191
  const shaped = model as {
183
192
  name?: string
184
193
  annotations?: { platform?: { validate?: Record<string, unknown> } }
@@ -210,6 +219,10 @@ export function manifestValidators(ast: unknown): Record<string, Record<string,
210
219
 
211
220
  const out: Record<string, Record<string, ManifestHookEntry>> = {}
212
221
  for (const model of models) {
222
+ // Guarded before the optional chains, not by them: `x?.y` protects against `x.y` being
223
+ // nullish, not against `x` itself being null, so a malformed entry in the models array would
224
+ // throw a TypeError out of `supatype push` rather than being skipped.
225
+ if (typeof model !== "object" || model === null) continue
213
226
  const shaped = model as {
214
227
  annotations?: {
215
228
  db?: { tableName?: string }
@@ -281,6 +294,11 @@ export const DEFAULT_HOOK_TIMEOUT_MS = 2000
281
294
  * the worse half of that: the schema says the field is checked, and no error appears anywhere,
282
295
  * because the write simply succeeds.
283
296
  *
297
+ * `cache` is here for a third reason of the same kind: the declaration is a ceiling the server
298
+ * enforces, and a manifest carrying a stale one lets a table be cached that the schema has since
299
+ * stopped permitting — or stops one the schema now allows, which is merely slow rather than wrong.
300
+ * The first of those is why it is written at push and not left to drift.
301
+ *
284
302
  * **Only updates a manifest that is already there.** Creating one from scratch here would be a
285
303
  * hazard: `functions_enabled` is a plain bool on the server's side, so a manifest carrying only
286
304
  * hooks would read as functions *disabled*, the exact defect this repo fixed a commit ago, arriving
@@ -302,7 +320,8 @@ export function syncManifestHooks(cwd: string, ast: unknown): boolean {
302
320
 
303
321
  const changedHooks = applyManifestMap(parsed, "hooks", manifestHooks(ast))
304
322
  const changedValidators = applyManifestMap(parsed, "validators", manifestValidators(ast))
305
- if (!changedHooks && !changedValidators) return false
323
+ const changedCache = applyManifestMap(parsed, "cache", manifestCache(ast))
324
+ if (!changedHooks && !changedValidators && !changedCache) return false
306
325
 
307
326
  writeFileSync(manifestPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8")
308
327
  return true
@@ -315,10 +334,15 @@ export function syncManifestHooks(cwd: string, ast: unknown): boolean {
315
334
  * when it decides whether the manifest predates the feature, and an empty object left behind by a
316
335
  * schema that no longer declares any is not the same statement.
317
336
  */
337
+ /**
338
+ * `Record<string, unknown>` rather than a hook map: this never looks inside a value, it compares
339
+ * the serialised form and swaps it. It was typed to hooks only because hooks were the first caller,
340
+ * and that narrowness is what a third key would otherwise have had to work around.
341
+ */
318
342
  function applyManifestMap(
319
343
  manifest: Record<string, unknown>,
320
344
  key: string,
321
- map: Record<string, Record<string, ManifestHookEntry>>,
345
+ map: Record<string, unknown>,
322
346
  ): boolean {
323
347
  const next = JSON.stringify(map)
324
348
  if (next === JSON.stringify(manifest[key] ?? {})) return false
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Which models keep drafts and version history, read from the extracted schema.
3
+ *
4
+ * The answer shapes things decided before any database exists: whether `PGRST_DB_SCHEMA` names the
5
+ * generated `draft` schema, and whether the push refuses a model that also masks a column. So it is
6
+ * read from the AST rather than asked of Postgres, the same way
7
+ * [`field-masking-tier`](./field-masking-tier.ts) decides the masking tier offline.
8
+ *
9
+ * Shaped loosely on purpose: the CLI's AST carries `options` as an opaque record, and nothing here
10
+ * needs more than "is `versions` present, and what did it ask for".
11
+ */
12
+
13
+ import { existsSync } from "node:fs"
14
+ import { loadSchemaAst } from "./config.js"
15
+ import type { SupatypeProjectConfig } from "./project-config.js"
16
+ import type { ExtractedSchemaAstV2 } from "./schema-ast-v2.js"
17
+ import { draftVisibilityRoles, previewLimits, schemaPathFromProject } from "./project-config.js"
18
+
19
+ /** Versions kept per record when the model states no retention. */
20
+ export const DEFAULT_VERSIONS_KEPT = 20
21
+
22
+ /** What a model's `versions` declaration resolves to. */
23
+ export type VersionsOptions = {
24
+ /** Editing writes a draft version rather than the live row. */
25
+ drafts: boolean
26
+ /** Versions kept per record, oldest pruned beyond it. */
27
+ keep: number
28
+ }
29
+
30
+ /** One versioned model, as the callers of this module need it. */
31
+ export type VersionedModel = {
32
+ /** The model's declared name, for an error message that names what the author wrote. */
33
+ name: string
34
+ /** Its table, which is what `<table>_versions` and `draft.<table>` are named after. */
35
+ tableName: string
36
+ options: VersionsOptions
37
+ }
38
+
39
+ type LooseModel = {
40
+ name?: unknown
41
+ options?: Record<string, unknown>
42
+ annotations?: { db?: { tableName?: unknown }; platform?: { access?: Record<string, unknown> } }
43
+ }
44
+
45
+ /**
46
+ * Normalise a `versions` value from the AST.
47
+ *
48
+ * `true` is shorthand for `{ drafts: true }` with the default retention, and a non-positive or
49
+ * unparseable `keep` falls back to the default rather than to "keep nothing": pruning to zero would
50
+ * delete the draft the author is editing.
51
+ */
52
+ export function resolveVersionsOptions(raw: unknown): VersionsOptions | undefined {
53
+ if (raw === true) return { drafts: true, keep: DEFAULT_VERSIONS_KEPT }
54
+ if (typeof raw !== "object" || raw === null) return undefined
55
+ const record = raw as { drafts?: unknown; keep?: unknown }
56
+ const keep =
57
+ typeof record.keep === "number" && Number.isFinite(record.keep) && record.keep >= 1
58
+ ? Math.floor(record.keep)
59
+ : DEFAULT_VERSIONS_KEPT
60
+ return { drafts: record.drafts !== false, keep }
61
+ }
62
+
63
+ /** Every model in the schema that declares `versions`, in declaration order. */
64
+ export function versionedModels(ast: unknown): VersionedModel[] {
65
+ const models = (ast as { models?: unknown[] } | null)?.models
66
+ if (!Array.isArray(models)) return []
67
+
68
+ const out: VersionedModel[] = []
69
+ for (const entry of models) {
70
+ const model = entry as LooseModel | null
71
+ if (model === null || typeof model !== "object") continue
72
+ const options = resolveVersionsOptions(model.options?.["versions"])
73
+ if (options === undefined) continue
74
+ const name = typeof model.name === "string" ? model.name : ""
75
+ const tableName = model.annotations?.db?.tableName
76
+ out.push({
77
+ name,
78
+ tableName: typeof tableName === "string" ? tableName : name,
79
+ options,
80
+ })
81
+ }
82
+ return out
83
+ }
84
+
85
+ /** Whether anything in the schema is versioned, which is what exposes the `draft` schema. */
86
+ export function schemaHasVersionedModels(ast: unknown): boolean {
87
+ return versionedModels(ast).length > 0
88
+ }
89
+
90
+ /**
91
+ * Models declaring both `versions` and per-column rules, which cannot both be enforced.
92
+ *
93
+ * A version snapshot is opaque `jsonb`. `supatype_mask` is driven by security labels on a specific
94
+ * table's column and rewrites references to *that* column, and the view tier puts the masking
95
+ * expression in a view over the real columns: neither can see inside a snapshot. So a masked value
96
+ * sits in plain sight in the versions table for anyone able to read it, and the push refuses rather
97
+ * than applying a schema whose restrictions have a hole in them.
98
+ *
99
+ * Per model, and that matters: under the view tier the managed schema comes off the exposed list but
100
+ * the API roles keep their privileges on the base tables, so a `security_invoker` view in an exposed
101
+ * schema — which is exactly what `draft.<table>` is — reaches past the `api` layer. A project may
102
+ * hold a masked model and a versioned model at once; one model may not be both.
103
+ */
104
+ export function modelsWithVersionsAndFieldRules(ast: unknown): string[] {
105
+ const models = (ast as { models?: unknown[] } | null)?.models
106
+ if (!Array.isArray(models)) return []
107
+
108
+ const out: string[] = []
109
+ for (const entry of models) {
110
+ const model = entry as LooseModel | null
111
+ if (model === null || typeof model !== "object") continue
112
+ if (resolveVersionsOptions(model.options?.["versions"]) === undefined) continue
113
+ const fields = model.annotations?.platform?.access?.["fields"]
114
+ if (typeof fields !== "object" || fields === null || Object.keys(fields).length === 0) continue
115
+ out.push(typeof model.name === "string" ? model.name : "(unnamed model)")
116
+ }
117
+ return out
118
+ }
119
+
120
+ /**
121
+ * Whether this project on disk has any versioned model.
122
+ *
123
+ * A schema that fails to load reads as "no versioned models", the narrower answer: deciding the
124
+ * exposed-schema list is the wrong moment to report a syntax error, and `push` and `dev` do it
125
+ * properly moments later with the file and line. Narrower rather than wider on purpose, since the
126
+ * failure mode of guessing wrong here is exposing a schema of drafts.
127
+ */
128
+ export function projectHasVersionedModels(cwd: string, config: SupatypeProjectConfig): boolean {
129
+ try {
130
+ const schemaPath = schemaPathFromProject(config, cwd)
131
+ if (!existsSync(schemaPath)) return false
132
+ return schemaHasVersionedModels(loadSchemaAst(schemaPath, cwd))
133
+ } catch {
134
+ return false
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The AST with this project's publishing settings attached, when anything is versioned.
140
+ *
141
+ * The engine compiles draft visibility into the generated policies, and it **refuses** a push whose
142
+ * schema has versioned models and carries no settings rather than applying a default. That refusal
143
+ * is the reason this is safe to add at a handful of call sites instead of somewhere unmissable: a
144
+ * path that forgets fails loudly at push time, naming what is missing, rather than quietly
145
+ * narrowing or widening who can read unpublished content.
146
+ *
147
+ * Returns the AST untouched for a project with no versioned model, so nothing else grows a key it
148
+ * has no use for.
149
+ */
150
+ export function withPublishing(
151
+ ast: ExtractedSchemaAstV2,
152
+ config: SupatypeProjectConfig,
153
+ ): ExtractedSchemaAstV2 {
154
+ if (!schemaHasVersionedModels(ast)) return ast
155
+
156
+ const limits = previewLimits(config)
157
+ return {
158
+ ...ast,
159
+ publishing: {
160
+ draftVisibility: draftVisibilityRoles(config),
161
+ previewDefaultTtl: limits.defaultTtl,
162
+ previewMaxRecordTtl: limits.maxRecordTtl,
163
+ previewMaxProjectTtl: limits.maxProjectTtl,
164
+ previewAllowProjectScope: limits.allowProjectScope,
165
+ },
166
+ }
167
+ }
@@ -16,6 +16,15 @@ export interface PgOptions {
16
16
  port: number
17
17
  /** Path to write the postgres log file. */
18
18
  logPath?: string
19
+ /**
20
+ * Port for the RESP keyspace, when the native install carries pg_keyspace.
21
+ *
22
+ * Absent means do not serve RESP at all: `supatype dev` then starts the
23
+ * Valkey sidecar instead, as it always has.
24
+ */
25
+ keyspacePort?: number
26
+ /** Database durable keys are written to. Ignored while everything is ephemeral. */
27
+ keyspaceDatabase?: string
19
28
  }
20
29
 
21
30
  /**
@@ -83,31 +92,116 @@ export function initdb(opts: PgOptions): void {
83
92
  * `lib/postgresql/`.
84
93
  */
85
94
  export function nativeMaskLibraryPresent(pgBinDir: string | null | undefined): boolean {
95
+ return nativeLibraryPresent(pgBinDir, "supatype_mask")
96
+ }
97
+
98
+ /**
99
+ * Whether a native Postgres install carries the RESP keyspace.
100
+ *
101
+ * Asked of the filesystem for the same reason as the mask: an archive
102
+ * downloaded before pg_keyspace was bundled has a Postgres without it, and the
103
+ * Windows archive has none at all — pgrx has no Windows target. Naming it in
104
+ * `shared_preload_libraries` anyway would stop the server starting with "could
105
+ * not access file", which would turn a missing cache into no database.
106
+ */
107
+ export function nativeKeyspaceLibraryPresent(pgBinDir: string | null | undefined): boolean {
108
+ return nativeLibraryPresent(pgBinDir, "pg_keyspace")
109
+ }
110
+
111
+ /**
112
+ * Two layouts: Linux and macOS put extension libraries in `lib/`, the Windows
113
+ * archive in `lib/postgresql/`.
114
+ */
115
+ function nativeLibraryPresent(pgBinDir: string | null | undefined, stem: string): boolean {
86
116
  if (!pgBinDir) return false
87
117
  const prefix = resolvePath(pgBinDir, "..")
88
- return ["supatype_mask.so", "supatype_mask.dylib", "supatype_mask.dll"].some(
118
+ return [`${stem}.so`, `${stem}.dylib`, `${stem}.dll`].some(
89
119
  (lib) =>
90
120
  existsSync(join(prefix, "lib", lib)) || existsSync(join(prefix, "lib", "postgresql", lib)),
91
121
  )
92
122
  }
93
123
 
124
+ /**
125
+ * Where the native RESP keyspace looks for a free port, and how far it looks.
126
+ *
127
+ * 6379 first, because that is what a developer's tooling and the Valkey
128
+ * sidecar before it both used, so the familiar port stays the usual answer.
129
+ */
130
+ export const KEYSPACE_PORT_BASE = 6379
131
+ export const KEYSPACE_PORT_SPAN = 10
132
+
133
+ /**
134
+ * The first free port in [base, base + span), or null when they are all taken.
135
+ *
136
+ * Binding a port something else already holds would leave Postgres up and the
137
+ * keyspace silently dead — the failure lands in the postmaster log and nowhere
138
+ * a developer is looking — so the port is chosen before the server starts
139
+ * rather than fixed and hoped for.
140
+ */
141
+ export async function firstFreePort(base: number, span: number): Promise<number | null> {
142
+ for (let port = base; port < base + span; port++) {
143
+ if (!(await isPortInUse(port))) return port
144
+ }
145
+ return null
146
+ }
147
+
148
+ /**
149
+ * The `-o` options pg_ctl passes to the server.
150
+ *
151
+ * Separated from the spawn so the decision is testable. What goes in here is
152
+ * decided by which libraries the archive happens to carry, which is exactly
153
+ * the kind of thing that is wrong in one combination nobody runs locally.
154
+ *
155
+ * Order in `shared_preload_libraries` is load-bearing: pg_keyspace first, then
156
+ * supatype_mask, so the mask stays outermost. pg_keyspace checks this itself
157
+ * and refuses to serve when the order is wrong — deliberately, since RESP
158
+ * would otherwise hand back rows the mask never rewrote.
159
+ *
160
+ * The sizing is the measured dev profile: ~60 MiB of shared memory, against
161
+ * the extension's own defaults of ~689 MiB before rings and row cache. It is
162
+ * reserved at postmaster start whether or not anything caches, so a local
163
+ * Postgres should not ask for a production keyspace. Durability is ephemeral
164
+ * throughout: nothing in a dev keyspace is the only copy of anything.
165
+ */
166
+ export function pgServerOptions(opts: PgOptions): string {
167
+ const mask = nativeMaskLibraryPresent(opts.pgBinDir)
168
+ const keyspace = opts.keyspacePort !== undefined && nativeKeyspaceLibraryPresent(opts.pgBinDir)
169
+
170
+ const libs = [...(keyspace ? ["pg_keyspace"] : []), ...(mask ? ["supatype_mask"] : [])]
171
+ const parts = [`-p ${opts.port}`]
172
+ if (libs.length > 0) parts.push(`-c shared_preload_libraries=${libs.join(",")}`)
173
+ if (keyspace) {
174
+ parts.push(
175
+ `-c pg_keyspace.port=${opts.keyspacePort}`,
176
+ "-c pg_keyspace.durability=ephemeral",
177
+ "-c pg_keyspace.keys=50000",
178
+ "-c pg_keyspace.ring_mb=8",
179
+ "-c pg_keyspace.rowcache_mb=1",
180
+ )
181
+ if (opts.keyspaceDatabase) {
182
+ parts.push(`-c pg_keyspace.database=${opts.keyspaceDatabase}`)
183
+ }
184
+ }
185
+ return parts.join(" ")
186
+ }
187
+
94
188
  export function start(opts: PgOptions): void {
95
189
  const bin = pgBin(opts.pgBinDir, "pg_ctl")
96
190
  const logPath = opts.logPath ?? join(opts.dataDir, "postgres.log")
97
191
 
98
- // `supatype_mask` is a planner hook, so it has to be preloaded, `CREATE EXTENSION` alone does
99
- // nothing without it. Only when the archive actually carries the library: an install downloaded
100
- // before it was bundled would fail to start with "could not access file".
192
+ // `supatype_mask` is a planner hook and pg_keyspace registers background
193
+ // workers and shared memory, so both have to be preloaded — `CREATE
194
+ // EXTENSION` alone does nothing for either. Only when the archive actually
195
+ // carries them; see pgServerOptions.
101
196
  //
102
- // Native Postgres was started with no preloaded libraries at all until now, which is why field
103
- // rules could not be enforced on the default `supatype dev`.
104
- const preload = nativeMaskLibraryPresent(opts.pgBinDir) ? " -c shared_preload_libraries=supatype_mask" : ""
105
-
197
+ // Native Postgres was started with no preloaded libraries at all until the
198
+ // mask, which is why field rules could not be enforced on the default
199
+ // `supatype dev`.
106
200
  const args = [
107
201
  "start",
108
202
  "-D", opts.dataDir,
109
203
  "-l", logPath,
110
- "-o", `-p ${opts.port}${preload}`,
204
+ "-o", pgServerOptions(opts),
111
205
  "--wait",
112
206
  ]
113
207
 
@@ -0,0 +1,52 @@
1
+ import type { SupatypeProjectConfig } from "./project-config.js"
2
+
3
+ /**
4
+ * Preview addresses that can never resolve, named before a push writes them.
5
+ *
6
+ * A preview address may be a path when this deployment serves the project's app: `app.mode` of
7
+ * `static` or `proxy` puts the app at `/` on the same origin as the API, and Studio resolves the
8
+ * path against the origin it is loaded from. That is the point of allowing a path at all, because
9
+ * the origin is a deployment fact and repeating it in config only lets it go stale.
10
+ *
11
+ * With `app.mode: "none"` nothing is served at `/`. A path then resolves against an origin that
12
+ * answers 404, so the share control hands somebody a link that looks right and opens nothing. The
13
+ * failure surfaces to whoever was sent the link rather than to whoever configured it, which is the
14
+ * worst place for it to land, so it is refused here instead.
15
+ *
16
+ * Returns the models at fault, so a caller can name all of them in one message rather than making
17
+ * an operator fix them one push at a time.
18
+ */
19
+ export function modelsWithUnresolvablePreview(config: SupatypeProjectConfig): string[] {
20
+ if (config.app.mode !== "none") return []
21
+
22
+ const livePreview = config.admin?.livePreview
23
+ if (livePreview === undefined) return []
24
+
25
+ return Object.entries(livePreview)
26
+ .filter(([, entry]) => {
27
+ const address =
28
+ entry.urlPattern !== undefined && entry.urlPattern !== "" ? entry.urlPattern : entry.url
29
+ // Nothing stated at all is not this check's business: Studio asks for the setting, and a
30
+ // project that has not configured previews for a model is not misconfigured.
31
+ if (address === undefined || address === "") return false
32
+ return !namesAnOrigin(address)
33
+ })
34
+ .map(([model]) => model)
35
+ .sort()
36
+ }
37
+
38
+ /** True when the address carries its own origin and needs nothing added to it. */
39
+ function namesAnOrigin(address: string): boolean {
40
+ // Protocol-relative counts: `//example.com/x` already names a host.
41
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(address) || address.startsWith("//")
42
+ }
43
+
44
+ /** The refusal headline. The two ways out are hints, in the caller, as every other refusal does. */
45
+ export function unresolvablePreviewMessage(models: string[]): string {
46
+ const plural = models.length > 1
47
+ return (
48
+ `admin.livePreview gives a path for ${models.join(", ")}, but app.mode is "none", ` +
49
+ `so this deployment serves nothing at / for ${plural ? "those paths" : "that path"} ` +
50
+ `to resolve against.`
51
+ )
52
+ }