@supatype/cli 0.2.1 → 0.3.1

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 (173) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +193 -157
  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/admin.d.ts.map +1 -1
  20. package/dist/commands/admin.js +21 -4
  21. package/dist/commands/admin.js.map +1 -1
  22. package/dist/commands/cache.d.ts +1 -1
  23. package/dist/commands/cache.js +3 -3
  24. package/dist/commands/cache.js.map +1 -1
  25. package/dist/commands/cloud.d.ts.map +1 -1
  26. package/dist/commands/cloud.js +2 -1
  27. package/dist/commands/cloud.js.map +1 -1
  28. package/dist/commands/deploy.d.ts.map +1 -1
  29. package/dist/commands/deploy.js +2 -1
  30. package/dist/commands/deploy.js.map +1 -1
  31. package/dist/commands/dev.d.ts.map +1 -1
  32. package/dist/commands/dev.js +61 -8
  33. package/dist/commands/dev.js.map +1 -1
  34. package/dist/commands/diff.d.ts.map +1 -1
  35. package/dist/commands/diff.js +2 -1
  36. package/dist/commands/diff.js.map +1 -1
  37. package/dist/commands/functions.d.ts.map +1 -1
  38. package/dist/commands/functions.js +6 -2
  39. package/dist/commands/functions.js.map +1 -1
  40. package/dist/commands/init.d.ts.map +1 -1
  41. package/dist/commands/init.js +13 -9
  42. package/dist/commands/init.js.map +1 -1
  43. package/dist/commands/keys.d.ts +35 -0
  44. package/dist/commands/keys.d.ts.map +1 -1
  45. package/dist/commands/keys.js +90 -6
  46. package/dist/commands/keys.js.map +1 -1
  47. package/dist/commands/migrate.d.ts.map +1 -1
  48. package/dist/commands/migrate.js +2 -1
  49. package/dist/commands/migrate.js.map +1 -1
  50. package/dist/commands/push.d.ts.map +1 -1
  51. package/dist/commands/push.js +61 -1
  52. package/dist/commands/push.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 +13 -0
  57. package/dist/dev-compose.d.ts.map +1 -1
  58. package/dist/dev-compose.js +119 -16
  59. package/dist/dev-compose.js.map +1 -1
  60. package/dist/engine-floor.d.ts +11 -1
  61. package/dist/engine-floor.d.ts.map +1 -1
  62. package/dist/engine-floor.js +49 -16
  63. package/dist/engine-floor.js.map +1 -1
  64. package/dist/functions-deno-types.d.ts +9 -0
  65. package/dist/functions-deno-types.d.ts.map +1 -1
  66. package/dist/functions-deno-types.js +45 -1
  67. package/dist/functions-deno-types.js.map +1 -1
  68. package/dist/functions-router-gen.d.ts.map +1 -1
  69. package/dist/functions-router-gen.js +113 -81
  70. package/dist/functions-router-gen.js.map +1 -1
  71. package/dist/kong-config.d.ts +1 -1
  72. package/dist/model-cache.d.ts +44 -0
  73. package/dist/model-cache.d.ts.map +1 -0
  74. package/dist/model-cache.js +106 -0
  75. package/dist/model-cache.js.map +1 -0
  76. package/dist/model-hooks.d.ts +5 -0
  77. package/dist/model-hooks.d.ts.map +1 -1
  78. package/dist/model-hooks.js +29 -1
  79. package/dist/model-hooks.js.map +1 -1
  80. package/dist/model-versioning.d.ts +80 -0
  81. package/dist/model-versioning.d.ts.map +1 -0
  82. package/dist/model-versioning.js +140 -0
  83. package/dist/model-versioning.js.map +1 -0
  84. package/dist/postgres-ctl.d.ts +55 -0
  85. package/dist/postgres-ctl.d.ts.map +1 -1
  86. package/dist/postgres-ctl.js +84 -8
  87. package/dist/postgres-ctl.js.map +1 -1
  88. package/dist/preview-config-check.d.ts +21 -0
  89. package/dist/preview-config-check.d.ts.map +1 -0
  90. package/dist/preview-config-check.js +47 -0
  91. package/dist/preview-config-check.js.map +1 -0
  92. package/dist/project-config.d.ts +182 -5
  93. package/dist/project-config.d.ts.map +1 -1
  94. package/dist/project-config.js +125 -8
  95. package/dist/project-config.js.map +1 -1
  96. package/dist/resolve-target.d.ts +9 -0
  97. package/dist/resolve-target.d.ts.map +1 -1
  98. package/dist/resolve-target.js.map +1 -1
  99. package/dist/rest-cache-admin.d.ts.map +1 -1
  100. package/dist/rest-cache-admin.js +6 -2
  101. package/dist/rest-cache-admin.js.map +1 -1
  102. package/dist/schema-ast-v2.d.ts +32 -1
  103. package/dist/schema-ast-v2.d.ts.map +1 -1
  104. package/dist/schema-ast-v2.js +14 -1
  105. package/dist/schema-ast-v2.js.map +1 -1
  106. package/dist/self-host-compose.d.ts +20 -1
  107. package/dist/self-host-compose.d.ts.map +1 -1
  108. package/dist/self-host-compose.js +227 -29
  109. package/dist/self-host-compose.js.map +1 -1
  110. package/dist/studio-admin-roles.d.ts +8 -1
  111. package/dist/studio-admin-roles.d.ts.map +1 -1
  112. package/dist/studio-admin-roles.js +16 -2
  113. package/dist/studio-admin-roles.js.map +1 -1
  114. package/dist/type-extractor.d.ts +7 -0
  115. package/dist/type-extractor.d.ts.map +1 -1
  116. package/dist/type-extractor.js +234 -3
  117. package/dist/type-extractor.js.map +1 -1
  118. package/package.json +2 -2
  119. package/src/api-config-cache.ts +142 -0
  120. package/src/cache-identity-scope.ts +92 -0
  121. package/src/cache-provider.ts +61 -0
  122. package/src/cli-version-embedded.ts +1 -1
  123. package/src/commands/add.ts +1 -1
  124. package/src/commands/admin.ts +23 -6
  125. package/src/commands/cache.ts +3 -3
  126. package/src/commands/cloud.ts +2 -1
  127. package/src/commands/deploy.ts +2 -1
  128. package/src/commands/dev.ts +68 -7
  129. package/src/commands/diff.ts +2 -1
  130. package/src/commands/functions.ts +6 -2
  131. package/src/commands/init.ts +13 -9
  132. package/src/commands/keys.ts +112 -7
  133. package/src/commands/migrate.ts +2 -1
  134. package/src/commands/push.ts +68 -1
  135. package/src/compose-services.ts +2 -1
  136. package/src/dev-compose.ts +151 -15
  137. package/src/engine-floor.ts +73 -16
  138. package/src/functions-deno-types.ts +48 -1
  139. package/src/functions-router-gen.ts +113 -81
  140. package/src/kong-config.ts +1 -1
  141. package/src/model-cache.ts +122 -0
  142. package/src/model-hooks.ts +26 -2
  143. package/src/model-versioning.ts +167 -0
  144. package/src/postgres-ctl.ts +103 -9
  145. package/src/preview-config-check.ts +52 -0
  146. package/src/project-config.ts +273 -11
  147. package/src/resolve-target.ts +11 -1
  148. package/src/rest-cache-admin.ts +8 -2
  149. package/src/schema-ast-v2.ts +47 -0
  150. package/src/self-host-compose.ts +250 -29
  151. package/src/studio-admin-roles.ts +16 -2
  152. package/src/type-extractor.ts +281 -2
  153. package/tests/api-config-cache-seed.test.ts +180 -0
  154. package/tests/cache-identity-scope.test.ts +61 -0
  155. package/tests/cache-provider-config.test.ts +132 -0
  156. package/tests/engine-floor.test.ts +43 -0
  157. package/tests/external-database-compose.test.ts +74 -3
  158. package/tests/field-masking-tier.test.ts +3 -3
  159. package/tests/fixtures/identity-scope-corpus.json +42 -0
  160. package/tests/functions-concurrency.test.ts +71 -0
  161. package/tests/init.test.ts +36 -2
  162. package/tests/keys-write.test.ts +148 -0
  163. package/tests/model-cache-declaration.test.ts +229 -0
  164. package/tests/model-cache-manifest.test.ts +99 -0
  165. package/tests/model-hooks.test.ts +55 -0
  166. package/tests/model-versions.test.ts +229 -0
  167. package/tests/native-keyspace.test.ts +130 -0
  168. package/tests/preview-config-check.test.ts +104 -0
  169. package/tests/rest-cache-admin.test.ts +36 -0
  170. package/tests/runtime-contract.test.ts +176 -22
  171. package/tests/studio-admin-roles.test.ts +32 -0
  172. package/tests/type-extractor.test.ts +113 -0
  173. package/tsconfig.tsbuildinfo +1 -1
@@ -59,9 +59,28 @@ export function generateFunctionsRouterSource(
59
59
  const routes = fns.map((fn, i) => ` "${fn.name}": handler_${i},`)
60
60
 
61
61
  return `// Auto-generated: do not edit (regenerated by supatype dev / functions serve)
62
+ import { AsyncLocalStorage } from "node:async_hooks"
62
63
  ${imports.join("\n")}
63
64
 
64
- const handlers: Record<string, (req: Request) => Response | Promise<Response>> = {
65
+ /**
66
+ * What a function is told about the call it is serving.
67
+ *
68
+ * Passed as the second argument rather than injected into the process environment, so
69
+ * invocations do not have to
70
+ * be serialised to keep one call's configuration out of another's.
71
+ */
72
+ interface FunctionContext {
73
+ readonly executionId: string
74
+ readonly functionName: string
75
+ readonly url: string
76
+ readonly anonKey: string
77
+ readonly serviceRoleKey?: string
78
+ readonly dbUrl?: string
79
+ readonly region: string
80
+ readonly env: Readonly<Record<string, string>>
81
+ }
82
+
83
+ const handlers: Record<string, (req: Request, ctx: FunctionContext) => Response | Promise<Response>> = {
65
84
  ${routes.join("\n")}
66
85
  }
67
86
 
@@ -71,17 +90,57 @@ const normalizedFunctionsDir = functionsDir.endsWith("/") ? functionsDir.slice(0
71
90
  const sharedEnvPath = Deno.env.get("SUPATYPE_SHARED_ENV_FILE")
72
91
  ?? (normalizedFunctionsDir ? normalizedFunctionsDir + "/.env.local" : "")
73
92
 
74
- let envLock: Promise<void> = Promise.resolve()
93
+ // Nothing per-invocation is written to Deno.env, so invocations run concurrently.
94
+ //
95
+ // This used to hold a lock across the whole handler because the config was delivered by mutating
96
+ // the process environment, which is global: two concurrent calls would read each other's values.
97
+ // The lock made the router run one invocation at a time, so a function waiting on a slow API
98
+ // blocked every other function locally, and the deployed worker had the same lock, so local
99
+ // behaved exactly like production and the serialisation was invisible.
100
+ const invocationStore = new AsyncLocalStorage<{ executionId: string; functionName: string }>()
101
+
102
+ const realConsole = {
103
+ debug: console.debug.bind(console),
104
+ info: console.info.bind(console),
105
+ log: console.log.bind(console),
106
+ warn: console.warn.bind(console),
107
+ error: console.error.bind(console),
108
+ }
109
+
110
+ function projectRef(): string {
111
+ return (Deno.env.get("SUPATYPE_PROJECT_REF") ?? "").trim() || "unknown"
112
+ }
75
113
 
76
- async function withEnvLock<T>(run: () => Promise<T>): Promise<T> {
77
- const prev = envLock
78
- let release: () => void = () => {}
79
- envLock = new Promise<void>(resolve => { release = resolve })
80
- await prev
114
+ function emit(level: string, message: string, extra: Record<string, unknown> = {}): void {
115
+ const current = invocationStore.getStore()
116
+ const line = {
117
+ timestamp: new Date().toISOString(),
118
+ level,
119
+ service: "functions-worker",
120
+ project_ref: projectRef(),
121
+ ...(current ? { request_id: current.executionId, function: current.functionName } : {}),
122
+ message,
123
+ ...extra,
124
+ }
125
+ const write = level === "error" ? realConsole.error : realConsole.log
126
+ write(JSON.stringify(line))
127
+ }
128
+
129
+ function formatArg(value: unknown): string {
130
+ if (typeof value === "string") return value
131
+ if (value instanceof Error) return value.stack ?? value.name + ": " + value.message
81
132
  try {
82
- return await run()
83
- } finally {
84
- release()
133
+ return JSON.stringify(value) ?? String(value)
134
+ } catch {
135
+ return String(value)
136
+ }
137
+ }
138
+
139
+ // A function's own console output, structured and attributed to the call that wrote it. Captured
140
+ // before any handler runs so nothing it logs escapes unlabelled.
141
+ for (const [method, level] of [["debug", "debug"], ["info", "info"], ["log", "info"], ["warn", "warn"], ["error", "error"]] as const) {
142
+ console[method] = (...args: unknown[]): void => {
143
+ emit(level, args.map(formatArg).join(" "))
85
144
  }
86
145
  }
87
146
 
@@ -90,12 +149,17 @@ async function readEnvFile(path: string): Promise<Record<string, string>> {
90
149
  try {
91
150
  const text = await Deno.readTextFile(path)
92
151
  const out: Record<string, string> = {}
93
- for (const line of text.split("\\n")) {
94
- const trimmed = line.trim()
95
- if (!trimmed || trimmed.startsWith("#")) continue
96
- const eq = trimmed.indexOf("=")
152
+ for (const raw of text.split("\\n")) {
153
+ const line = raw.trim()
154
+ if (!line || line.startsWith("#")) continue
155
+ const eq = line.indexOf("=")
97
156
  if (eq <= 0) continue
98
- out[trimmed.slice(0, eq)] = trimmed.slice(eq + 1)
157
+ const key = line.slice(0, eq).trim()
158
+ let value = line.slice(eq + 1).trim()
159
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
160
+ value = value.slice(1, -1)
161
+ }
162
+ out[key] = value
99
163
  }
100
164
  return out
101
165
  } catch {
@@ -106,32 +170,16 @@ async function readEnvFile(path: string): Promise<Record<string, string>> {
106
170
  async function scopedEnvForFunction(fnName: string): Promise<Record<string, string>> {
107
171
  const shared = await readEnvFile(sharedEnvPath)
108
172
  if (!normalizedFunctionsDir) return shared
109
- const fnPath = normalizedFunctionsDir + "/.env." + fnName + ".local"
110
- const fnVars = await readEnvFile(fnPath)
173
+ const fnVars = await readEnvFile(normalizedFunctionsDir + "/.env." + fnName + ".local")
111
174
  return { ...shared, ...fnVars }
112
175
  }
113
176
 
114
- async function runWithScopedEnv<T>(fnName: string, run: () => Promise<T>): Promise<T> {
115
- return withEnvLock(async () => {
116
- const scoped = await scopedEnvForFunction(fnName)
117
- const prev = new Map<string, string | undefined>()
118
- for (const [k, v] of Object.entries(scoped)) {
119
- prev.set(k, Deno.env.get(k))
120
- Deno.env.set(k, v)
121
- }
122
- try {
123
- return await run()
124
- } finally {
125
- for (const k of Object.keys(scoped)) {
126
- const old = prev.get(k)
127
- if (old === undefined) Deno.env.delete(k)
128
- else Deno.env.set(k, old)
129
- }
130
- }
131
- })
132
- }
133
-
134
- Deno.serve({ port }, async (req: Request): Promise<Response> => {
177
+ Deno.serve({
178
+ port,
179
+ onListen: ({ hostname, port: bound }) => {
180
+ emit("info", "functions router listening on http://" + hostname + ":" + bound)
181
+ },
182
+ }, async (req: Request): Promise<Response> => {
135
183
  const url = new URL(req.url)
136
184
  const pathParts = url.pathname
137
185
  .replace(/^\\/functions\\/v1\\/?/, "")
@@ -142,58 +190,42 @@ Deno.serve({ port }, async (req: Request): Promise<Response> => {
142
190
  if (!fnName || !handlers[fnName]) {
143
191
  return new Response(JSON.stringify({
144
192
  error: "not_found",
145
- message: fnName ? \`Function "\${fnName}" not found\` : "No function specified",
193
+ message: fnName ? 'Function "' + fnName + '" not found' : "No function specified",
146
194
  available: Object.keys(handlers),
147
195
  }), { status: 404, headers: { "Content-Type": "application/json" } })
148
196
  }
149
197
 
150
- try {
151
- const start = performance.now()
152
- const response = await runWithScopedEnv(fnName, async () => {
153
- const prev = new Map<string, string | undefined>()
154
- const setScoped = (key: string, value: string | undefined) => {
155
- if (value === undefined || value.length === 0) return
156
- prev.set(key, Deno.env.get(key))
157
- Deno.env.set(key, value)
158
- }
198
+ const anonKey = Deno.env.get("SUPATYPE_ANON_KEY") ?? ""
199
+ const serviceRoleKey = Deno.env.get("SUPATYPE_SERVICE_ROLE_KEY")
200
+ const dbUrl = Deno.env.get("SUPATYPE_DB_URL") ?? Deno.env.get("DATABASE_URL")
201
+ const context: FunctionContext = {
202
+ executionId: crypto.randomUUID(),
203
+ functionName: fnName,
204
+ url: Deno.env.get("SUPATYPE_URL") ?? "",
205
+ anonKey,
206
+ ...(serviceRoleKey ? { serviceRoleKey } : {}),
207
+ ...(dbUrl ? { dbUrl } : {}),
208
+ region: Deno.env.get("SUPATYPE_REGION") ?? "local",
209
+ env: await scopedEnvForFunction(fnName),
210
+ }
159
211
 
160
- // Supatype runtime defaults available to every invocation.
161
- const supatypeUrl = Deno.env.get("SUPATYPE_URL")
162
- const supatypeAnon = Deno.env.get("SUPATYPE_ANON_KEY")
163
- const supatypeServiceRole = Deno.env.get("SUPATYPE_SERVICE_ROLE_KEY")
164
- const supatypeDbUrl = Deno.env.get("SUPATYPE_DB_URL") ?? Deno.env.get("DATABASE_URL")
165
- const supatypeJwks = Deno.env.get("SUPATYPE_JWKS")
166
-
167
- setScoped("SUPATYPE_URL", supatypeUrl)
168
- setScoped("SUPATYPE_ANON_KEY", supatypeAnon)
169
- setScoped("SUPATYPE_SERVICE_ROLE_KEY", supatypeServiceRole)
170
- setScoped("SUPATYPE_DB_URL", supatypeDbUrl)
171
- setScoped("SUPATYPE_JWKS", supatypeJwks)
172
- if (!Deno.env.get("SUPATYPE_PUBLISHABLE_KEYS") && supatypeAnon) {
173
- setScoped("SUPATYPE_PUBLISHABLE_KEYS", JSON.stringify({ anon: supatypeAnon }))
174
- }
175
- if (!Deno.env.get("SUPATYPE_SECRET_KEYS") && supatypeServiceRole) {
176
- setScoped("SUPATYPE_SECRET_KEYS", JSON.stringify({ service_role: supatypeServiceRole }))
177
- }
212
+ const start = performance.now()
178
213
 
179
- setScoped("SUPATYPE_REGION", Deno.env.get("SUPATYPE_REGION") ?? "local")
180
- setScoped("SUPATYPE_EXECUTION_ID", crypto.randomUUID())
181
- setScoped("DENO_DEPLOYMENT_ID", Deno.env.get("DENO_DEPLOYMENT_ID") ?? "local-dev")
182
-
183
- try {
184
- return await handlers[fnName]!(req)
185
- } finally {
186
- for (const [key, old] of prev.entries()) {
187
- if (old === undefined) Deno.env.delete(key)
188
- else Deno.env.set(key, old)
189
- }
190
- }
214
+ try {
215
+ const response = await invocationStore.run(
216
+ { executionId: context.executionId, functionName: fnName },
217
+ async () => handlers[fnName]!(req, context),
218
+ )
219
+ emit("info", req.method + " /functions/v1/" + fnName + " -> " + response.status, {
220
+ status: response.status,
221
+ duration_ms: Number((performance.now() - start).toFixed(1)),
191
222
  })
192
- const duration = (performance.now() - start).toFixed(1)
193
- console.log(\`\${req.method} /functions/v1/\${fnName} → \${response.status} (\${duration}ms)\`)
194
223
  return response
195
224
  } catch (err) {
196
- console.error(\`Error in function "\${fnName}":\`, err)
225
+ emit("error", err instanceof Error ? err.message : "Unknown error", {
226
+ status: 500,
227
+ duration_ms: Number((performance.now() - start).toFixed(1)),
228
+ })
197
229
  return new Response(JSON.stringify({
198
230
  error: "function_error",
199
231
  message: err instanceof Error ? err.message : "Unknown error",
@@ -19,7 +19,7 @@ export interface KongDeclarativeOptions {
19
19
  /** See {@link RuntimeRouteOptions.studioStripPath}. */
20
20
  studioStripPath?: boolean | undefined
21
21
  /**
22
- * When set, append a global Kong `acme` plugin (Let's Encrypt) with Redis/Valkey
22
+ * When set, append a global Kong `acme` plugin (Let's Encrypt) with RESP
23
23
  * storage so the self-host gateway provisions and renews TLS automatically.
24
24
  */
25
25
  acme?: { email: string; domain: string; redisHost: string } | undefined
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Lift each model's `cache` declaration into the shape the route manifest carries.
3
+ *
4
+ * Keyed by **table**, like `hooks` and `validators`, because that is what a REST path carries — the
5
+ * model name never reaches the wire.
6
+ *
7
+ * ## Why the manifest and not the AST
8
+ *
9
+ * `supatype push` writes the declaration into `annotations.platform.cache` too, and the server does
10
+ * not read it there. The schema engine writes `_supatype.schema_state.ast_snapshot` as
11
+ * `serde_json::to_value(&ast)` — its own parsed struct, re-serialised — and its
12
+ * `PlatformModelAnnotations` knows only `access` and `searchFields`. An unknown key is not an error
13
+ * to serde; it is silently dropped. So a declaration left to ride the AST would vanish between here
14
+ * and the server with nothing anywhere reporting it.
15
+ *
16
+ * `hooks` has always taken this route for the same reason. Plan §13.1 said otherwise and said to
17
+ * verify before relying on it; this is the verified path.
18
+ */
19
+ import { readEnvValue, upsertEnvFile } from "./env-file.js"
20
+ import type { ModelCacheAst } from "./schema-ast-v2.js"
21
+
22
+ /** One table's cache declaration, as the manifest carries it. */
23
+ export interface ManifestCacheEntry {
24
+ enabled?: boolean
25
+ maxTtl?: number
26
+ public?: boolean
27
+ rows?: boolean
28
+ }
29
+
30
+ interface ShapedModel {
31
+ annotations?: {
32
+ db?: { tableName?: string }
33
+ platform?: { cache?: ModelCacheAst }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Every model's cache declaration, keyed by table name.
39
+ *
40
+ * A model that declared nothing is absent rather than present-and-empty. The distinction is
41
+ * load-bearing: the declaration is a ceiling, so "no entry" means *nothing may cache this table*,
42
+ * and an empty entry would read as "declared, permitting nothing" — the same outcome by a route
43
+ * that looks like a bug.
44
+ */
45
+ export function manifestCache(ast: unknown): Record<string, ManifestCacheEntry> {
46
+ const models = (ast as { models?: unknown[] })?.models
47
+ if (!Array.isArray(models)) return {}
48
+
49
+ const out: Record<string, ManifestCacheEntry> = {}
50
+ for (const model of models) {
51
+ // Guarded before the optional chains, not by them: `x?.y` protects against `x.y` being
52
+ // nullish, not against `x` itself being null — so a malformed entry in the models array would
53
+ // throw a TypeError out of `supatype push` rather than being skipped.
54
+ if (typeof model !== "object" || model === null) continue
55
+ const shaped = model as ShapedModel
56
+ const table = shaped.annotations?.db?.tableName
57
+ const cache = shaped.annotations?.platform?.cache
58
+ if (typeof table !== "string" || table.length === 0) continue
59
+ if (typeof cache !== "object" || cache === null) continue
60
+
61
+ const entry: ManifestCacheEntry = {}
62
+ // Copied key by key rather than spread, so a field added to the AST shape has to be added here
63
+ // deliberately. The manifest is a wire format read by another process; silently widening it is
64
+ // how the two ends stop agreeing about what a field means.
65
+ if (typeof cache.enabled === "boolean") entry.enabled = cache.enabled
66
+ if (typeof cache.public === "boolean") entry.public = cache.public
67
+ if (typeof cache.rows === "boolean") entry.rows = cache.rows
68
+ if (typeof cache.maxTtl === "number" && Number.isFinite(cache.maxTtl) && cache.maxTtl >= 0) {
69
+ entry.maxTtl = cache.maxTtl
70
+ }
71
+
72
+ if (Object.keys(entry).length > 0) out[table] = entry
73
+ }
74
+ return out
75
+ }
76
+
77
+ // ─── Row cache enablement ────────────────────────────────────────────────────
78
+
79
+ /** The two env names the Postgres image reads to turn Mode B on. */
80
+ export const ROWCACHE_DECODE_ENV = "SUPATYPE_KEYSPACE_ROWCACHE_DECODE"
81
+ export const ROWCACHE_READTHROUGH_ENV = "SUPATYPE_KEYSPACE_ROWCACHE_READTHROUGH"
82
+
83
+ /** Whether any model declares `cache: { rows: true }`. */
84
+ export function declaresRowCache(ast: unknown): boolean {
85
+ return Object.values(manifestCache(ast)).some((entry) => entry.rows === true)
86
+ }
87
+
88
+ /**
89
+ * Keep the row cache's two switches in `.env` matching what the schema declares.
90
+ *
91
+ * `cache: { rows: true }` registers a table with the row cache, and registration alone serves
92
+ * nothing: Mode B needs `pg_keyspace.rowcache_decode` for the invalidation worker and
93
+ * `rowcache_readthrough` to fill on a primary-key miss. Both are written by the image's entrypoint
94
+ * from these variables, before any server starts, so they cannot be switched at runtime and cannot
95
+ * be decided by the compose file alone: only a push knows whether any model declares `rows`.
96
+ *
97
+ * Without this the stack came up with the row-cache segment reserved, the tables registered, both
98
+ * switches off and nothing anywhere saying so. Studio's panel was the only thing that reported it,
99
+ * and it reported it as an operator's missing configuration rather than as a push that had not
100
+ * finished the job.
101
+ *
102
+ * Written to `.env` rather than baked into the compose file for the same reason `SUPATYPE_KONG_PORT`
103
+ * is: `self-host compose render` runs with no AST in hand, so a value the compose file hardcoded
104
+ * would be whatever the last render guessed.
105
+ *
106
+ * Returns the new state when it changed, and null when it did not. A change needs the database
107
+ * container recreated, which the caller is the one that can say.
108
+ */
109
+ export function syncRowCacheEnv(cwd: string, ast: unknown): "on" | "off" | null {
110
+ const want = declaresRowCache(ast)
111
+ const value = want ? "1" : "0"
112
+
113
+ const current = readEnvValue(cwd, ROWCACHE_DECODE_ENV, "")
114
+ const currentThrough = readEnvValue(cwd, ROWCACHE_READTHROUGH_ENV, "")
115
+ if (current === value && currentThrough === value) return null
116
+
117
+ upsertEnvFile(cwd, {
118
+ [ROWCACHE_DECODE_ENV]: value,
119
+ [ROWCACHE_READTHROUGH_ENV]: value,
120
+ })
121
+ return want ? "on" : "off"
122
+ }
@@ -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
+ }