@supatype/cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +190 -153
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/api-config-cache.d.ts +34 -0
- package/dist/api-config-cache.d.ts.map +1 -0
- package/dist/api-config-cache.js +123 -0
- package/dist/api-config-cache.js.map +1 -0
- package/dist/cache-identity-scope.d.ts +38 -0
- package/dist/cache-identity-scope.d.ts.map +1 -0
- package/dist/cache-identity-scope.js +70 -0
- package/dist/cache-identity-scope.js.map +1 -0
- package/dist/cache-provider.d.ts +30 -0
- package/dist/cache-provider.d.ts.map +1 -0
- package/dist/cache-provider.js +60 -0
- package/dist/cache-provider.js.map +1 -0
- package/dist/cli-version-embedded.js +1 -1
- package/dist/commands/add.js +1 -1
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/cache.d.ts +1 -1
- package/dist/commands/cache.js +3 -3
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +2 -1
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +61 -8
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/diff.d.ts.map +1 -1
- package/dist/commands/diff.js +2 -1
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +6 -2
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +13 -9
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/keys.d.ts +14 -0
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +37 -0
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +2 -1
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +52 -1
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/self-host.d.ts.map +1 -1
- package/dist/commands/self-host.js +9 -0
- package/dist/commands/self-host.js.map +1 -1
- package/dist/compose-services.d.ts.map +1 -1
- package/dist/compose-services.js +2 -1
- package/dist/compose-services.js.map +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +18 -7
- package/dist/dev-compose.js.map +1 -1
- package/dist/engine-floor.d.ts +11 -1
- package/dist/engine-floor.d.ts.map +1 -1
- package/dist/engine-floor.js +49 -16
- package/dist/engine-floor.js.map +1 -1
- package/dist/functions-deno-types.d.ts +9 -0
- package/dist/functions-deno-types.d.ts.map +1 -1
- package/dist/functions-deno-types.js +45 -1
- package/dist/functions-deno-types.js.map +1 -1
- package/dist/functions-router-gen.d.ts.map +1 -1
- package/dist/functions-router-gen.js +113 -81
- package/dist/functions-router-gen.js.map +1 -1
- package/dist/kong-config.d.ts +1 -1
- package/dist/model-cache.d.ts +17 -0
- package/dist/model-cache.d.ts.map +1 -0
- package/dist/model-cache.js +45 -0
- package/dist/model-cache.js.map +1 -0
- package/dist/model-hooks.d.ts +5 -0
- package/dist/model-hooks.d.ts.map +1 -1
- package/dist/model-hooks.js +29 -1
- package/dist/model-hooks.js.map +1 -1
- package/dist/model-versioning.d.ts +80 -0
- package/dist/model-versioning.d.ts.map +1 -0
- package/dist/model-versioning.js +140 -0
- package/dist/model-versioning.js.map +1 -0
- package/dist/postgres-ctl.d.ts +55 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +84 -8
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/preview-config-check.d.ts +21 -0
- package/dist/preview-config-check.d.ts.map +1 -0
- package/dist/preview-config-check.js +47 -0
- package/dist/preview-config-check.js.map +1 -0
- package/dist/project-config.d.ts +182 -5
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +125 -8
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +9 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js.map +1 -1
- package/dist/rest-cache-admin.d.ts.map +1 -1
- package/dist/rest-cache-admin.js +6 -2
- package/dist/rest-cache-admin.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +32 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +14 -1
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/self-host-compose.d.ts +20 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +181 -27
- package/dist/self-host-compose.js.map +1 -1
- package/dist/studio-admin-roles.d.ts +8 -1
- package/dist/studio-admin-roles.d.ts.map +1 -1
- package/dist/studio-admin-roles.js +16 -2
- package/dist/studio-admin-roles.js.map +1 -1
- package/dist/type-extractor.d.ts +7 -0
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +234 -3
- package/dist/type-extractor.js.map +1 -1
- package/package.json +2 -2
- package/src/api-config-cache.ts +142 -0
- package/src/cache-identity-scope.ts +92 -0
- package/src/cache-provider.ts +61 -0
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/add.ts +1 -1
- package/src/commands/cache.ts +3 -3
- package/src/commands/cloud.ts +2 -1
- package/src/commands/deploy.ts +2 -1
- package/src/commands/dev.ts +68 -7
- package/src/commands/diff.ts +2 -1
- package/src/commands/functions.ts +6 -2
- package/src/commands/init.ts +13 -9
- package/src/commands/keys.ts +40 -0
- package/src/commands/migrate.ts +2 -1
- package/src/commands/push.ts +57 -1
- package/src/commands/self-host.ts +9 -0
- package/src/compose-services.ts +2 -1
- package/src/dev-compose.ts +18 -7
- package/src/engine-floor.ts +73 -16
- package/src/functions-deno-types.ts +48 -1
- package/src/functions-router-gen.ts +113 -81
- package/src/kong-config.ts +1 -1
- package/src/model-cache.ts +74 -0
- package/src/model-hooks.ts +26 -2
- package/src/model-versioning.ts +167 -0
- package/src/postgres-ctl.ts +103 -9
- package/src/preview-config-check.ts +52 -0
- package/src/project-config.ts +273 -11
- package/src/resolve-target.ts +11 -1
- package/src/rest-cache-admin.ts +8 -2
- package/src/schema-ast-v2.ts +47 -0
- package/src/self-host-compose.ts +204 -27
- package/src/studio-admin-roles.ts +16 -2
- package/src/type-extractor.ts +281 -2
- package/tests/api-config-cache-seed.test.ts +180 -0
- package/tests/cache-identity-scope.test.ts +61 -0
- package/tests/cache-provider-config.test.ts +132 -0
- package/tests/engine-floor.test.ts +43 -0
- package/tests/external-database-compose.test.ts +74 -3
- package/tests/field-masking-tier.test.ts +3 -3
- package/tests/fixtures/identity-scope-corpus.json +42 -0
- package/tests/functions-concurrency.test.ts +71 -0
- package/tests/init.test.ts +36 -2
- package/tests/model-cache-declaration.test.ts +229 -0
- package/tests/model-cache-manifest.test.ts +99 -0
- package/tests/model-hooks.test.ts +55 -0
- package/tests/model-versions.test.ts +229 -0
- package/tests/native-keyspace.test.ts +130 -0
- package/tests/preview-config-check.test.ts +104 -0
- package/tests/rest-cache-admin.test.ts +36 -0
- package/tests/runtime-contract.test.ts +176 -22
- package/tests/self-host-keys.test.ts +92 -0
- package/tests/studio-admin-roles.test.ts +32 -0
- package/tests/type-extractor.test.ts +113 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/dev-compose.ts
CHANGED
|
@@ -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
|
-
|
|
337
|
-
|
|
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")
|
package/src/engine-floor.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
99
|
-
|
|
153
|
+
for (const requirement of ENGINE_REQUIREMENTS) {
|
|
154
|
+
if (compareVersions(pinnedEngineVersion, requirement.since) >= 0) continue
|
|
100
155
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
`
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
83
|
-
}
|
|
84
|
-
|
|
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
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
96
|
-
const eq =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 ?
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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",
|
package/src/kong-config.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|