@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
@@ -2,6 +2,7 @@
2
2
  * `supatype dev` when `provider: docker`, full self-host Compose stack (Kong gateway).
3
3
  */
4
4
 
5
+ import { withPublishing } from "./model-versioning.js"
5
6
  import { existsSync, mkdirSync, writeFileSync } from "node:fs"
6
7
  import { homedir } from "node:os"
7
8
  import { dirname, join, resolve } from "node:path"
@@ -328,13 +329,23 @@ export function upsertDevComposeEnv(
328
329
  (key) => !(wantsLocalServer && key === "SUPATYPE_SERVER_IMAGE"),
329
330
  )
330
331
  const removeImageKeys = managedImageKeys.filter((key) => !(key in imagePins))
332
+ // The server refuses to start when *any* retired name is in scope, and it reads the project's
333
+ // config files into its environment before checking, so a `.env` this CLI wrote under the old
334
+ // spelling is fatal to a stack that has otherwise been upgraded. Only the key this file used to
335
+ // write is removed: an operator's own GOTRUE_ variable is theirs, and the server's error names it
336
+ // and its replacement precisely enough to fix by hand.
337
+ const retiredKeys = ["GOTRUE_MAILER_AUTOCONFIRM"]
331
338
  upsertEnvFile(cwd, updates, {
332
339
  removeManaged: removeImageKeys,
333
340
  managed: managedImageKeys,
334
341
  // A local image left in `.env` after the project stopped asking for one would keep pointing
335
342
  // compose at a stale build, and it carries no marker for `removeManaged` to act on.
336
- ...(!wantsLocalServer &&
337
- !("SUPATYPE_SERVER_IMAGE" in imagePins) && { remove: ["SUPATYPE_SERVER_IMAGE"] }),
343
+ remove: [
344
+ ...retiredKeys,
345
+ ...(!wantsLocalServer && !("SUPATYPE_SERVER_IMAGE" in imagePins)
346
+ ? ["SUPATYPE_SERVER_IMAGE"]
347
+ : []),
348
+ ],
338
349
  })
339
350
  }
340
351
 
@@ -635,7 +646,7 @@ async function runComposeSchemaPush(
635
646
  schemaPath: string,
636
647
  composeProject: string,
637
648
  ): Promise<void> {
638
- const ast = loadSchemaAst(schemaPath, cwd)
649
+ const ast = withPublishing(loadSchemaAst(schemaPath, cwd), config)
639
650
  const astJson = JSON.stringify(ast)
640
651
 
641
652
  const supatypeDir = join(cwd, ".supatype")
@@ -868,7 +879,7 @@ export async function diffSchemaDocker(cwd: string, config: SupatypeProjectConfi
868
879
  const brand = { intro: "Schema diff" }
869
880
  await ensureDockerDbPublishedForHostEngine(cwd, config, brand)
870
881
  const schemaPath = schemaPathFromProject(config, cwd)
871
- const ast = loadSchemaAst(schemaPath, cwd)
882
+ const ast = withPublishing(loadSchemaAst(schemaPath, cwd), config)
872
883
  await ensureEngine()
873
884
  return engineRequest<DiffResult>("/diff", {
874
885
  ast,
@@ -890,7 +901,7 @@ export async function diffSchemaDocker(cwd: string, config: SupatypeProjectConfi
890
901
  await startComposeDatabase(config, paths, cwd, project, diffBrand)
891
902
 
892
903
  const schemaPath = schemaPathFromProject(config, cwd)
893
- const ast = loadSchemaAst(schemaPath, cwd)
904
+ const ast = withPublishing(loadSchemaAst(schemaPath, cwd), config)
894
905
 
895
906
  const supatypeDir = join(cwd, ".supatype")
896
907
  mkdirSync(supatypeDir, { recursive: true })
@@ -945,7 +956,7 @@ export async function pushSchemaDocker(cwd: string, config: SupatypeProjectConfi
945
956
  await startComposeDatabase(config, paths, cwd, project, pushBrand)
946
957
 
947
958
  const schemaPath = schemaPathFromProject(config, cwd)
948
- const ast = loadSchemaAst(schemaPath, cwd)
959
+ const ast = withPublishing(loadSchemaAst(schemaPath, cwd), config)
949
960
  await runComposeSchemaPush(cwd, config, paths, schemaPath, project)
950
961
 
951
962
  const upGateway = runDockerCompose(paths.composePath, ["up", "-d"], cwd, project, {
@@ -1157,7 +1168,7 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
1157
1168
  startedAt: new Date().toISOString(),
1158
1169
  })
1159
1170
 
1160
- const ast = loadSchemaAst(schemaPath, cwd)
1171
+ const ast = withPublishing(loadSchemaAst(schemaPath, cwd), config)
1161
1172
  await provisionDockerStorageBuckets(ast, kongPort, serviceRoleKey)
1162
1173
 
1163
1174
  const pidDir = join(homedir(), ".supatype", "projects", config.project.name, "pid")
@@ -27,6 +27,15 @@ import type { ExtractedSchemaAstV2, ModelAstV2 } from "./schema-ast-v2.js"
27
27
  /** First engine release that creates the `_supatype` constraint helpers (schema-engine v0.2.0). */
28
28
  export const ENGINE_MIN_FOR_BOUNDS = "0.2.0"
29
29
 
30
+ /**
31
+ * First engine release that emits the drafts and publishing layer (schema-engine v0.3.0).
32
+ *
33
+ * The failure this guards is silent rather than loud, which is why it is worth a check of its own:
34
+ * an older engine does not reject `versions`, it ignores the key. The push succeeds, the model has
35
+ * no snapshot table, and Studio saves drafts into nothing.
36
+ */
37
+ export const ENGINE_MIN_FOR_VERSIONS = "0.3.0"
38
+
30
39
  /**
31
40
  * Compare two dotted versions numerically. Returns <0, 0 or >0.
32
41
  *
@@ -80,8 +89,55 @@ export function boundsRequiringHelpers(ast: ExtractedSchemaAstV2): string[] {
80
89
  return [...fieldsWithBounds(ast.models), ...modelsWithConstraints(ast.models)]
81
90
  }
82
91
 
92
+ /** Models declaring `versions`, which needs a whole layer only a new enough engine emits. */
93
+ export function modelsRequiringVersions(ast: ExtractedSchemaAstV2): string[] {
94
+ return ast.models
95
+ .filter((model) => model.options["versions"] !== undefined)
96
+ .map((model) => model.name)
97
+ }
98
+
83
99
  /**
84
- * Refuse a push whose schema needs helpers this engine does not create.
100
+ * One thing a schema can declare that an older engine cannot apply.
101
+ *
102
+ * A table rather than a second copy of the check, because there will be a third: every feature that
103
+ * puts something in the database which only the engine creates lands here, and the shape of the
104
+ * refusal should not be rewritten each time.
105
+ */
106
+ interface EngineRequirement {
107
+ /** First engine release that can apply it. */
108
+ since: string
109
+ /** What in this schema needs it, named so the message can point at it. */
110
+ declaredBy: (ast: ExtractedSchemaAstV2) => string[]
111
+ /** What the author wrote. */
112
+ feature: string
113
+ /** What happens if it is applied anyway, in the words the failure would use. */
114
+ consequence: string
115
+ }
116
+
117
+ const ENGINE_REQUIREMENTS: EngineRequirement[] = [
118
+ {
119
+ since: ENGINE_MIN_FOR_BOUNDS,
120
+ declaredBy: boundsRequiringHelpers,
121
+ feature: "bounds",
122
+ consequence:
123
+ `Bounds compile to CHECK constraints that call helpers in the _supatype schema, and only ` +
124
+ `engine ${ENGINE_MIN_FOR_BOUNDS}+ creates them. Applying this would fail inside Postgres ` +
125
+ `with "function _supatype.richtext_text(jsonb) does not exist".`,
126
+ },
127
+ {
128
+ since: ENGINE_MIN_FOR_VERSIONS,
129
+ declaredBy: modelsRequiringVersions,
130
+ feature: "`versions`",
131
+ consequence:
132
+ `Only engine ${ENGINE_MIN_FOR_VERSIONS}+ emits the snapshot tables, the draft views and the ` +
133
+ `publish functions. An older engine ignores the declaration and applies the rest, so the ` +
134
+ `push reports success and you get a model with no history, no drafts and an editor that ` +
135
+ `saves into nothing. That silence is the reason this refuses.`,
136
+ },
137
+ ]
138
+
139
+ /**
140
+ * Refuse a push whose schema needs something this engine cannot apply.
85
141
  *
86
142
  * Takes the pin from `versions.engine`, or undefined when unpinned. See the note above on why
87
143
  * that is the right source rather than the binary's own `--version`.
@@ -93,21 +149,22 @@ export function assertEngineSupportsSchema(
93
149
  // Unpinned resolves to latest, and `local` points at a build whose version the config does not
94
150
  // know. Neither can be compared, and neither is the case that breaks.
95
151
  if (pinnedEngineVersion === undefined || pinnedEngineVersion === "local") return
96
- if (compareVersions(pinnedEngineVersion, ENGINE_MIN_FOR_BOUNDS) >= 0) return
97
152
 
98
- const needed = boundsRequiringHelpers(ast)
99
- if (needed.length === 0) return
153
+ for (const requirement of ENGINE_REQUIREMENTS) {
154
+ if (compareVersions(pinnedEngineVersion, requirement.since) >= 0) continue
100
155
 
101
- const shown = needed.slice(0, 3).join(", ")
102
- const more = needed.length > 3 ? `, and ${needed.length - 3} more` : ""
103
- throw new Error(
104
- `This schema declares bounds that need schema-engine ${ENGINE_MIN_FOR_BOUNDS} or newer, ` +
105
- `and this project pins ${pinnedEngineVersion}.\n\n` +
106
- ` Declared on: ${shown}${more}\n\n` +
107
- `Bounds compile to CHECK constraints that call helpers in the _supatype schema, and only ` +
108
- `engine ${ENGINE_MIN_FOR_BOUNDS}+ creates them. Applying this would fail inside Postgres ` +
109
- `with "function _supatype.richtext_text(jsonb) does not exist".\n\n` +
110
- `Raise or remove the pin in supatype.config.ts:\n` +
111
- ` versions: { engine: "${ENGINE_MIN_FOR_BOUNDS}" } // or omit it to track latest`,
112
- )
156
+ const needed = requirement.declaredBy(ast)
157
+ if (needed.length === 0) continue
158
+
159
+ const shown = needed.slice(0, 3).join(", ")
160
+ const more = needed.length > 3 ? `, and ${needed.length - 3} more` : ""
161
+ throw new Error(
162
+ `This schema declares ${requirement.feature}, which needs schema-engine ` +
163
+ `${requirement.since} or newer, and this project pins ${pinnedEngineVersion}.\n\n` +
164
+ ` Declared on: ${shown}${more}\n\n` +
165
+ `${requirement.consequence}\n\n` +
166
+ `Raise or remove the pin in supatype.config.ts:\n` +
167
+ ` versions: { engine: "${requirement.since}" } // or omit it to track latest`,
168
+ )
169
+ }
113
170
  }
@@ -76,6 +76,44 @@ declare namespace Deno {
76
76
  }
77
77
 
78
78
  /** `functions/tsconfig.json`: owns function files so the app config can exclude them. */
79
+ /**
80
+ * The context type, as a module a function can import.
81
+ *
82
+ * Not an ambient declaration. `functions/deno.d.ts` is read by tsserver and by the tsconfig in this
83
+ * directory, but `deno check` builds its own type graph and does not look at either, so a function
84
+ * naming an ambient type fails the check the runtime itself would run. An import works in both.
85
+ */
86
+ export function functionsContextModuleSource(): string {
87
+ return `/**
88
+ * What a function is told about the call it is serving.
89
+ *
90
+ * Handed to the handler as its second argument. Per-invocation values used to be injected into the
91
+ * process environment and read with Deno.env.get, which is global: two concurrent calls saw each
92
+ * other's configuration, including a service-role key granted to one route and not another. The
93
+ * worker therefore held a lock across every handler and ran one invocation at a time.
94
+ *
95
+ * Passing them in removes the global, so functions run concurrently.
96
+ *
97
+ * Regenerated by the CLI. Edits are overwritten.
98
+ */
99
+ export interface FunctionContext {
100
+ /** Unique to this invocation. Every log line it writes carries the same value. */
101
+ readonly executionId: string
102
+ /** The function or hook route being served. */
103
+ readonly functionName: string
104
+ /** The project's API base URL, reachable from inside the function. */
105
+ readonly url: string
106
+ readonly anonKey: string
107
+ /** Present only when the project granted this route the service role. */
108
+ readonly serviceRoleKey?: string
109
+ readonly dbUrl?: string
110
+ readonly region: string
111
+ /** Values from .env and .env.<function>.local. */
112
+ readonly env: Readonly<Record<string, string>>
113
+ }
114
+ `
115
+ }
116
+
79
117
  export function functionsTsConfigSource(): string {
80
118
  return `{
81
119
  "compilerOptions": {
@@ -97,6 +135,7 @@ export function functionsTsConfigSource(): string {
97
135
 
98
136
  export type FunctionsDenoTypesResult = {
99
137
  wroteDenoDts: boolean
138
+ wroteContextModule: boolean
100
139
  wroteTsconfig: boolean
101
140
  rootExclude: "updated" | "already" | "missing" | "manual"
102
141
  hints: string[]
@@ -121,6 +160,14 @@ export function ensureFunctionsDenoTypes(
121
160
  wroteDenoDts = true
122
161
  }
123
162
 
163
+ // Always rewritten, unlike the other two: this one is a contract the runtime enforces, so a stale
164
+ // copy is a type error in the user's editor rather than a missing convenience.
165
+ const contextDir = join(functionsDir, "_shared")
166
+ const contextPath = join(contextDir, "context.ts")
167
+ mkdirSync(contextDir, { recursive: true })
168
+ writeFileSync(contextPath, functionsContextModuleSource(), "utf8")
169
+ const wroteContextModule = true
170
+
124
171
  let wroteTsconfig = false
125
172
  if (!existsSync(tsconfigPath)) {
126
173
  writeFileSync(tsconfigPath, functionsTsConfigSource(), "utf8")
@@ -141,7 +188,7 @@ export function ensureFunctionsDenoTypes(
141
188
  )
142
189
  }
143
190
 
144
- return { wroteDenoDts, wroteTsconfig, rootExclude, hints }
191
+ return { wroteDenoDts, wroteContextModule, wroteTsconfig, rootExclude, hints }
145
192
  }
146
193
 
147
194
  function ensureRootTsconfigExcludesFunctions(
@@ -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,74 @@
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 type { ModelCacheAst } from "./schema-ast-v2.js"
20
+
21
+ /** One table's cache declaration, as the manifest carries it. */
22
+ export interface ManifestCacheEntry {
23
+ enabled?: boolean
24
+ maxTtl?: number
25
+ public?: boolean
26
+ rows?: boolean
27
+ }
28
+
29
+ interface ShapedModel {
30
+ annotations?: {
31
+ db?: { tableName?: string }
32
+ platform?: { cache?: ModelCacheAst }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Every model's cache declaration, keyed by table name.
38
+ *
39
+ * A model that declared nothing is absent rather than present-and-empty. The distinction is
40
+ * load-bearing: the declaration is a ceiling, so "no entry" means *nothing may cache this table*,
41
+ * and an empty entry would read as "declared, permitting nothing" — the same outcome by a route
42
+ * that looks like a bug.
43
+ */
44
+ export function manifestCache(ast: unknown): Record<string, ManifestCacheEntry> {
45
+ const models = (ast as { models?: unknown[] })?.models
46
+ if (!Array.isArray(models)) return {}
47
+
48
+ const out: Record<string, ManifestCacheEntry> = {}
49
+ for (const model of models) {
50
+ // Guarded before the optional chains, not by them: `x?.y` protects against `x.y` being
51
+ // nullish, not against `x` itself being null — so a malformed entry in the models array would
52
+ // throw a TypeError out of `supatype push` rather than being skipped.
53
+ if (typeof model !== "object" || model === null) continue
54
+ const shaped = model as ShapedModel
55
+ const table = shaped.annotations?.db?.tableName
56
+ const cache = shaped.annotations?.platform?.cache
57
+ if (typeof table !== "string" || table.length === 0) continue
58
+ if (typeof cache !== "object" || cache === null) continue
59
+
60
+ const entry: ManifestCacheEntry = {}
61
+ // Copied key by key rather than spread, so a field added to the AST shape has to be added here
62
+ // deliberately. The manifest is a wire format read by another process; silently widening it is
63
+ // how the two ends stop agreeing about what a field means.
64
+ if (typeof cache.enabled === "boolean") entry.enabled = cache.enabled
65
+ if (typeof cache.public === "boolean") entry.public = cache.public
66
+ if (typeof cache.rows === "boolean") entry.rows = cache.rows
67
+ if (typeof cache.maxTtl === "number" && Number.isFinite(cache.maxTtl) && cache.maxTtl >= 0) {
68
+ entry.maxTtl = cache.maxTtl
69
+ }
70
+
71
+ if (Object.keys(entry).length > 0) out[table] = entry
72
+ }
73
+ return out
74
+ }