@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
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { validateProjectConfig } from "../src/project-config.js"
3
+ import { cacheProvider } from "../src/cache-provider.js"
4
+
5
+ /**
6
+ * `cache.provider` picks the RESP server the response cache and Kong's ACME
7
+ * certificates share. Every rejection here is a case where accepting the
8
+ * config would produce a stack that runs, and runs something other than what
9
+ * was asked for.
10
+ */
11
+ const validate = (cache: unknown, database: unknown = { provider: "docker" }) =>
12
+ validateProjectConfig(
13
+ {
14
+ project: { name: "acme" },
15
+ database,
16
+ server: { mode: "dev" },
17
+ app: { mode: "none" },
18
+ ...(cache === undefined ? {} : { cache }),
19
+ },
20
+ "supatype.config.ts",
21
+ )
22
+
23
+ describe("cache.provider", () => {
24
+ it("accepts both providers, and no cache section at all", () => {
25
+ expect(() => validate(undefined)).not.toThrow()
26
+ expect(() => validate({})).not.toThrow()
27
+ expect(() => validate({ provider: "valkey" })).not.toThrow()
28
+ expect(() => validate({ provider: "pg_keyspace" })).not.toThrow()
29
+ })
30
+
31
+ it("refuses a provider it does not have", () => {
32
+ expect(() => validate({ provider: "redis" })).toThrow(/cache\.provider must be/)
33
+ })
34
+
35
+ // Rejected rather than silently downgraded to Valkey: a stack that quietly
36
+ // runs the thing you switched away from is worse than one that will not start.
37
+ it("refuses pg_keyspace against a database this stack does not start", () => {
38
+ expect(() =>
39
+ validate({ provider: "pg_keyspace" }, { external: { url: "postgres://u:p@h:5432/d" } }),
40
+ ).toThrow(/needs the Postgres this stack starts/)
41
+ })
42
+
43
+ it("leaves valkey alone with an external database", () => {
44
+ expect(() =>
45
+ validate({ provider: "valkey" }, { external: { url: "postgres://u:p@h:5432/d" } }),
46
+ ).not.toThrow()
47
+ })
48
+ })
49
+
50
+ describe("cache.durablePrefixes", () => {
51
+ it("accepts a list of prefixes", () => {
52
+ expect(() => validate({ provider: "pg_keyspace", durablePrefixes: ["session:"] })).not.toThrow()
53
+ })
54
+
55
+ it("refuses anything that is not a list of non-empty strings", () => {
56
+ for (const bad of ["session:", [""], [3], [null]]) {
57
+ expect(() => validate({ provider: "pg_keyspace", durablePrefixes: bad })).toThrow(
58
+ /must be an array of non-empty strings/,
59
+ )
60
+ }
61
+ })
62
+
63
+ // `,` separates entries and `=` separates a prefix from its tier in
64
+ // pg_keyspace.durability_overrides. A prefix containing either would be read
65
+ // as two settings, and the first sign of it would be a database that will
66
+ // not start — long after the config was written.
67
+ it("refuses a prefix carrying one of the separators", () => {
68
+ for (const bad of ["a,b", "a=b"]) {
69
+ expect(() => validate({ provider: "pg_keyspace", durablePrefixes: [bad] })).toThrow(
70
+ /cannot contain "," or "="/,
71
+ )
72
+ }
73
+ })
74
+ })
75
+
76
+ describe("which provider a project actually runs", () => {
77
+ /*
78
+ * P9 flipped the default. The resolver is one function because the answer depends on two fields:
79
+ * an absent `cache.provider` means pg_keyspace beside a database this stack starts and Valkey
80
+ * beside an external one. A default written into the parser would have to be wrong for one of
81
+ * them, or applied twice — and applied twice is how the compose file and the native path end up
82
+ * disagreeing about which server is running, which is a cache that silently never hits and, with
83
+ * TLS on, a Kong that cannot store its certificate.
84
+ */
85
+ const project = (over: Record<string, unknown>) =>
86
+ ({
87
+ project: { name: "acme" },
88
+ database: { provider: "docker" },
89
+ server: { mode: "dev" },
90
+ app: { mode: "none" },
91
+ ...over,
92
+ }) as unknown as Parameters<typeof cacheProvider>[0]
93
+
94
+ it("runs the keyspace in Postgres when nothing says otherwise", () => {
95
+ expect(cacheProvider(project({}))).toBe("pg_keyspace")
96
+ expect(cacheProvider(project({ cache: {} }))).toBe("pg_keyspace")
97
+ expect(cacheProvider(project({ cache: { durablePrefixes: ["session:"] } }))).toBe("pg_keyspace")
98
+ })
99
+
100
+ it("keeps Valkey for a project that pins its Postgres image", () => {
101
+ // The image honours SUPATYPE_KEYSPACE_ENABLED from the release that introduced it, and an
102
+ // older one ignores it and starts with no RESP listener — no error anywhere, just a cache that
103
+ // never hits. A pin says this project's image is fixed; a default cannot assume something
104
+ // about a fixed image it has no way to check. Saying `pg_keyspace` explicitly still works, and
105
+ // is the whole instruction for a project whose pin does carry the toggle.
106
+ expect(cacheProvider(project({ versions: { postgres: "17.2" } }))).toBe("valkey")
107
+ expect(
108
+ cacheProvider(project({ versions: { postgres: "17.2" }, cache: { provider: "pg_keyspace" } })),
109
+ ).toBe("pg_keyspace")
110
+ })
111
+
112
+ it("is unaffected by a pin on anything else", () => {
113
+ // Only the Postgres image decides whether a keyspace can be served.
114
+ expect(cacheProvider(project({ versions: { engine: "0.4.2", server: "0.1.0" } }))).toBe("pg_keyspace")
115
+ })
116
+
117
+ it("honours an explicit choice, including the one that is no longer the default", () => {
118
+ // Selectable, not deprecated. A project that wrote `valkey` down keeps the sidecar.
119
+ expect(cacheProvider(project({ cache: { provider: "valkey" } }))).toBe("valkey")
120
+ expect(cacheProvider(project({ cache: { provider: "pg_keyspace" } }))).toBe("pg_keyspace")
121
+ })
122
+
123
+ it("resolves to Valkey for a database this stack does not start", () => {
124
+ // Not a preference: pg_keyspace is loaded through shared_preload_libraries, so against an
125
+ // unmanaged Postgres there is nothing to configure and Valkey is the only thing that can run.
126
+ // Asking for pg_keyspace explicitly there is refused by validation rather than resolved here.
127
+ expect(cacheProvider(project({ database: { external: { url: "postgres://u:p@h:5432/d" } } }))).toBe("valkey")
128
+ expect(
129
+ cacheProvider(project({ database: { external: { url: "postgres://u:p@h:5432/d" } }, cache: {} })),
130
+ ).toBe("valkey")
131
+ })
132
+ })
@@ -4,6 +4,8 @@ import {
4
4
  boundsRequiringHelpers,
5
5
  compareVersions,
6
6
  ENGINE_MIN_FOR_BOUNDS,
7
+ ENGINE_MIN_FOR_VERSIONS,
8
+ modelsRequiringVersions,
7
9
  } from "../src/engine-floor.js"
8
10
  import type { ExtractedSchemaAstV2, FieldAstV2, ModelAstV2 } from "../src/schema-ast-v2.js"
9
11
 
@@ -100,3 +102,44 @@ describe("assertEngineSupportsSchema", () => {
100
102
  expect(() => assertEngineSupportsSchema(WITH_BOUND, "1.0.0")).not.toThrow()
101
103
  })
102
104
  })
105
+
106
+ describe("the versions floor", () => {
107
+ const versionedModel = (): ModelAstV2 => ({
108
+ ...model("Post", { title: field() }),
109
+ options: { versions: { drafts: true, keep: 20 } },
110
+ })
111
+ const VERSIONED = schema([versionedModel()])
112
+ const PLAIN = schema([model("Post", { title: field() })])
113
+
114
+ it("names the models that declare it", () => {
115
+ expect(modelsRequiringVersions(VERSIONED)).toEqual(["Post"])
116
+ expect(modelsRequiringVersions(PLAIN)).toEqual([])
117
+ })
118
+
119
+ it("refuses a pin older than the release that emits the layer", () => {
120
+ // The failure this guards is *silent*: an older engine does not reject `versions`, it ignores
121
+ // the key, so the push reports success and the model has no snapshot table and no drafts.
122
+ expect(() => { assertEngineSupportsSchema(VERSIONED, "0.2.0") }).toThrow(
123
+ /`versions`[\s\S]*0\.3\.0[\s\S]*saves into nothing/,
124
+ )
125
+ })
126
+
127
+ it("allows the release itself and anything newer", () => {
128
+ expect(() => { assertEngineSupportsSchema(VERSIONED, ENGINE_MIN_FOR_VERSIONS) }).not.toThrow()
129
+ expect(() => { assertEngineSupportsSchema(VERSIONED, "1.0.0") }).not.toThrow()
130
+ })
131
+
132
+ it("says nothing about a schema that declares no versions", () => {
133
+ // The floor must not spread to projects that never asked for the feature.
134
+ expect(() => { assertEngineSupportsSchema(PLAIN, "0.1.0") }).not.toThrow()
135
+ })
136
+
137
+ it("leaves an unpinned or local project alone", () => {
138
+ expect(() => { assertEngineSupportsSchema(VERSIONED, undefined) }).not.toThrow()
139
+ expect(() => { assertEngineSupportsSchema(VERSIONED, "local") }).not.toThrow()
140
+ })
141
+
142
+ it("still refuses bounds on an older pin, so the second rule did not shadow the first", () => {
143
+ expect(() => { assertEngineSupportsSchema(WITH_BOUND, "0.1.9") }).toThrow(/bounds/)
144
+ })
145
+ })
@@ -61,7 +61,7 @@ describe("external database: compose", () => {
61
61
  it("interpolates the URL rather than baking the password into the file", () => {
62
62
  // The compose file is generated into .supatype/, which operators do commit.
63
63
  const compose = renderSelfHostCompose(external())
64
- // "secret" alone would match minio's own S3 credentials, so match the userinfo form.
64
+ // "secret" alone would match the object store's own S3 credentials, so match the userinfo form.
65
65
  expect(compose).not.toContain("owner:secret@")
66
66
  expect(compose).not.toContain("://owner")
67
67
  expect(compose).toContain("${DATABASE_URL:?DATABASE_URL is missing from .env")
@@ -122,7 +122,7 @@ describe("external database: compose", () => {
122
122
 
123
123
  it("still declares the volumes it does own", () => {
124
124
  const compose = renderSelfHostCompose(external())
125
- expect(compose).toContain(" minio-data:")
125
+ expect(compose).toContain(" storage-data:")
126
126
  expect(compose).toContain(" valkey-data:")
127
127
  })
128
128
 
@@ -137,7 +137,7 @@ describe("external database: compose", () => {
137
137
  "realtime:",
138
138
  "control-plane:",
139
139
  "server:",
140
- "minio:",
140
+ "seaweedfs:",
141
141
  "schema-engine:",
142
142
  "studio:",
143
143
  "valkey:",
@@ -182,3 +182,74 @@ describe("a loopback external URL", () => {
182
182
  expect(loopbackExternalHost(managed())).toBeUndefined()
183
183
  })
184
184
  })
185
+
186
+ describe("the draft schema in generated compose", () => {
187
+ it("reaches PGRST_DB_SCHEMA when a model is versioned", () => {
188
+ // The wiring, not the list: a correct `apiSchemaList` behind a placeholder that never gets the
189
+ // flag is exactly the failure shape this project has hit before, and it reads as "there is no
190
+ // draft" rather than as an error.
191
+ const compose = renderSelfHostCompose(managed(), process.cwd(), { drafts: true })
192
+ expect(compose).toMatch(/PGRST_DB_SCHEMA: "public, draft, /)
193
+ })
194
+
195
+ it("leaves it off when nothing is versioned", () => {
196
+ const compose = renderSelfHostCompose(managed(), process.cwd(), { drafts: false })
197
+ expect(compose).toContain('PGRST_DB_SCHEMA: "public, supatype, graphql_public, auth"')
198
+ expect(compose).not.toContain("draft")
199
+ })
200
+ })
201
+
202
+ describe("the server's own configuration keys", () => {
203
+ // `renderSelfHostCompose` is pure over its config, so one render serves every
204
+ // assertion here and the fixture lives in one place.
205
+ const compose = renderSelfHostCompose(managed())
206
+
207
+ it("uses the SUPATYPE_ prefix the server actually reads", () => {
208
+ // The server's config prefix is "supatype", so a GOTRUE_ key is read by nothing. It does not
209
+ // warn about the ones it ignores: it dies on the first *required* key it cannot find, so
210
+ // self-host failed to start with a message naming a variable that was present in .env under
211
+ // another spelling, and no test noticed because none asserted these.
212
+ for (const key of [
213
+ "SUPATYPE_API_EXTERNAL_URL",
214
+ "SUPATYPE_API_HOST",
215
+ "SUPATYPE_API_PORT",
216
+ "SUPATYPE_DB_DRIVER",
217
+ "SUPATYPE_DB_DATABASE_URL",
218
+ "SUPATYPE_SITE_URL",
219
+ "SUPATYPE_JWT_SECRET",
220
+ "SUPATYPE_DISABLE_SIGNUP",
221
+ "SUPATYPE_MAILER_AUTOCONFIRM",
222
+ ]) {
223
+ expect(compose).toContain(`${key}:`)
224
+ }
225
+ })
226
+
227
+ it("sets no GOTRUE_ key on the server, since none of them are read", () => {
228
+ const serverBlock = compose.split("\n server:")[1]?.split("\n kong:")[0] ?? ""
229
+ const stale = serverBlock
230
+ .split("\n")
231
+ .map((line) => line.trim())
232
+ .filter((line) => /^GOTRUE_[A-Z_]+:/.test(line))
233
+ expect(stale).toEqual([])
234
+ })
235
+
236
+ it("reads its override from the current name too, not the retired one", () => {
237
+ // Interpolating from GOTRUE_MAILER_AUTOCONFIRM would require the operator to keep that key in
238
+ // `.env` — and the server reads config files into its environment before checking for retired
239
+ // names, so its presence there is fatal. The old spelling cannot be the escape hatch when
240
+ // having it set at all is the failure.
241
+ expect(compose).toContain("SUPATYPE_MAILER_AUTOCONFIRM: ${SUPATYPE_MAILER_AUTOCONFIRM:-true}")
242
+ expect(compose).not.toContain("${GOTRUE_MAILER_AUTOCONFIRM")
243
+ })
244
+
245
+ it("raises the header buffer, because localhost cookies are not port-scoped", () => {
246
+ // Every project a developer runs on localhost shares one cookie jar, so this gateway is sent
247
+ // cookies belonging to entirely unrelated stacks, and nginx's default buffers are not sized for
248
+ // that. The answer is "Request header or cookie too large" from a component nobody configured.
249
+ //
250
+ // The value is pinned rather than merely present, because Studio's own nginx must carry the
251
+ // same one: a request that clears the gateway and fails at the page is worse to diagnose than
252
+ // one that fails outright. packages/studio/nginx.conf holds the reasoning and its own test.
253
+ expect(compose).toContain('KONG_NGINX_HTTP_LARGE_CLIENT_HEADER_BUFFERS: "4 32k"')
254
+ })
255
+ })
@@ -118,7 +118,7 @@ describe("the exposed schema list", () => {
118
118
  it("serves from api under tier 2, and drops the managed schema", () => {
119
119
  // Both halves matter. A client picks its schema per request with `Accept-Profile`, so leaving the
120
120
  // managed schema exposed would let any caller read the unmasked table.
121
- const list = apiSchemaList(project({ provider: "native" }), "views")
121
+ const list = apiSchemaList(project({ provider: "native" }), { tier: "views" })
122
122
  expect(list).toBe("api, supatype, graphql_public, auth")
123
123
  // Entries, not substrings: `graphql_public` legitimately contains "public".
124
124
  expect(list.split(", ")).not.toContain("public")
@@ -126,7 +126,7 @@ describe("the exposed schema list", () => {
126
126
 
127
127
  it("is unchanged for tier 1 and for a schema with no field rules", () => {
128
128
  for (const tier of ["extension", "none", undefined] as const) {
129
- expect(apiSchemaList(project({ provider: "docker" }), tier)).toBe(
129
+ expect(apiSchemaList(project({ provider: "docker" }), { tier })).toBe(
130
130
  "public, supatype, graphql_public, auth",
131
131
  )
132
132
  }
@@ -138,7 +138,7 @@ describe("the exposed schema list", () => {
138
138
  const cfg = project({ external: { url: "postgres://u@h:5432/d" } }, {
139
139
  schema: { api_schemas: ["public", "supatype"] },
140
140
  })
141
- expect(apiSchemaList(cfg, "views")).toBe("public, supatype")
141
+ expect(apiSchemaList(cfg, { tier: "views" })).toBe("public, supatype")
142
142
  })
143
143
  })
144
144
 
@@ -0,0 +1,42 @@
1
+ {
2
+ "_comment": "Shared corpus for the identity-dependence rule, which exists twice: here, for supatype push (packages/cli/src/cache-identity-scope.ts), and in supatype-server (internal/studiobootstrap.IsIdentityDependent) for the runtime cache-scope decision. Two implementations of one rule drift silently — a push that says 'shareable' against a runtime that says 'not', or the reverse, which is a data leak. Both sides run these cases. Add a case here when you change either.",
3
+ "cases": [
4
+ { "name": "no rule at all", "rule": null, "identityDependent": false },
5
+ { "name": "Public", "rule": { "type": "public" }, "identityDependent": false },
6
+ { "name": "Private", "rule": { "type": "private" }, "identityDependent": false },
7
+
8
+ { "name": "LoggedIn", "rule": { "type": "authenticated" }, "identityDependent": true },
9
+ { "name": "Role<admin>", "rule": { "type": "role" }, "identityDependent": true },
10
+ { "name": "Owner<user_id>", "rule": { "type": "owner", "field": "user_id" }, "identityDependent": true },
11
+ { "name": "In<...>", "rule": { "type": "in" }, "identityDependent": true },
12
+ { "name": "Exists<...>", "rule": { "type": "exists" }, "identityDependent": true },
13
+
14
+ { "name": "Eq<user_id, CurrentUser>", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "authUid" } }, "identityDependent": true },
15
+ { "name": "CurrentUser on the left", "rule": { "type": "compare", "left": { "kind": "authUid" }, "right": { "kind": "column" } }, "identityDependent": true },
16
+ { "name": "compare against a claim", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "claim" } }, "identityDependent": true },
17
+ { "name": "compare against authRole", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "authRole" } }, "identityDependent": true },
18
+
19
+ { "name": "Lte<published_at, Now> — row-dependent but shareable", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "now" } }, "identityDependent": false },
20
+ { "name": "column vs literal", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "literal" } }, "identityDependent": false },
21
+ { "name": "column vs column", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "column" } }, "identityDependent": false },
22
+
23
+ { "name": "IsNull on a column", "rule": { "type": "nullCheck", "operand": { "kind": "column" } }, "identityDependent": false },
24
+ { "name": "IsNull on a claim", "rule": { "type": "nullCheck", "operand": { "kind": "claim" } }, "identityDependent": true },
25
+
26
+ { "name": "Any of two shareable", "rule": { "type": "any", "rules": [ { "type": "public" }, { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "now" } } ] }, "identityDependent": false },
27
+ { "name": "Any with one caller-varying", "rule": { "type": "any", "rules": [ { "type": "public" }, { "type": "owner" } ] }, "identityDependent": true },
28
+ { "name": "All of two shareable", "rule": { "type": "all", "rules": [ { "type": "public" }, { "type": "private" } ] }, "identityDependent": false },
29
+ { "name": "All with one caller-varying", "rule": { "type": "all", "rules": [ { "type": "public" }, { "type": "authenticated" } ] }, "identityDependent": true },
30
+ { "name": "Any with no rules", "rule": { "type": "any", "rules": [] }, "identityDependent": false },
31
+
32
+ { "name": "Not over shareable", "rule": { "type": "not", "rule": { "type": "public" } }, "identityDependent": false },
33
+ { "name": "Not over caller-varying", "rule": { "type": "not", "rule": { "type": "owner" } }, "identityDependent": true },
34
+ { "name": "Not over nothing", "rule": { "type": "not" }, "identityDependent": false },
35
+
36
+ { "name": "nested any/all/not, shareable throughout", "rule": { "type": "all", "rules": [ { "type": "any", "rules": [ { "type": "public" } ] }, { "type": "not", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "now" } } } ] }, "identityDependent": false },
37
+ { "name": "caller-varying buried three deep", "rule": { "type": "all", "rules": [ { "type": "any", "rules": [ { "type": "not", "rule": { "type": "compare", "left": { "kind": "column" }, "right": { "kind": "authUid" } } } ] } ] }, "identityDependent": true },
38
+
39
+ { "name": "unrecognised rule type", "rule": { "type": "somethingNew" }, "identityDependent": true },
40
+ { "name": "rule with no type", "rule": {}, "identityDependent": true }
41
+ ]
42
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { generateFunctionsRouterSource } from "../src/functions-router-gen.js"
3
+
4
+ // The router the CLI generates for `supatype dev` used to serialise every invocation.
5
+ //
6
+ // Per-invocation configuration was delivered by mutating `Deno.env`, which is process-global, so
7
+ // two concurrent calls would read each other's values — including a service-role key granted to one
8
+ // route and not the other. The defence was a lock held across the whole handler, which meant one
9
+ // invocation at a time: a function waiting two seconds on a third-party API blocked every other
10
+ // function for two seconds.
11
+ //
12
+ // It was invisible because the deployed worker had the same lock, so local behaved exactly like
13
+ // production and there was nothing to compare against. Measured after the fix: four 300ms
14
+ // invocations complete in about 430ms through the worker and 655ms through this router, against
15
+ // ~1200ms serial.
16
+ //
17
+ // These assert the shape rather than the timing. A timing test here would need a Deno runtime and
18
+ // would be the slowest test in the suite; what actually regresses is somebody reintroducing the
19
+ // global mutation, and that is visible in the source.
20
+
21
+ const ROUTER = generateFunctionsRouterSource("/project/.supatype/functions-router.ts", [
22
+ { name: "hello", entrypoint: "/project/functions/hello/index.ts" },
23
+ ])
24
+
25
+ describe("the generated functions router", () => {
26
+ it("holds no lock across a handler", () => {
27
+ // Any of these names coming back means the serialisation is back.
28
+ for (const banned of ["envLock", "withEnvLock", "runWithScopedEnv"]) {
29
+ expect(ROUTER).not.toContain(banned)
30
+ }
31
+ })
32
+
33
+ it("does not write per-invocation values into the process environment", () => {
34
+ // `Deno.env.set` is the mechanism, and there is no safe amount of it: the environment is shared
35
+ // by every invocation in the isolate, so one write is enough to need the lock back.
36
+ expect(ROUTER).not.toContain("Deno.env.set")
37
+ expect(ROUTER).not.toContain("Deno.env.delete")
38
+ })
39
+
40
+ it("passes the call's configuration to the handler instead", () => {
41
+ expect(ROUTER).toContain("ctx: FunctionContext")
42
+ expect(ROUTER).toContain("handlers[fnName]!(req, context)")
43
+ // The service-role key reaches a route through the context or not at all.
44
+ expect(ROUTER).toContain("serviceRoleKey")
45
+ })
46
+
47
+ it("keeps each invocation's identity in async context, not in a module variable", () => {
48
+ // `console` is global and calls now genuinely interleave, so a captured line has to be
49
+ // attributed to the invocation that wrote it. A module-level "current request" would
50
+ // mis-attribute every interleaved line.
51
+ expect(ROUTER).toContain("AsyncLocalStorage")
52
+ expect(ROUTER).toContain("invocationStore.run(")
53
+ })
54
+
55
+ it("logs one JSON object per line, with the fields the cloud pipeline indexes on", () => {
56
+ // Promtail parses each line as JSON and labels on these, taking the timestamp from a `timestamp`
57
+ // field. A plain-text line is collected, matches no expression, and is indexed under no project.
58
+ for (const field of ["timestamp", "level", "service", "project_ref", "request_id", "duration_ms"]) {
59
+ expect(ROUTER).toContain(field)
60
+ }
61
+ expect(ROUTER).toContain("JSON.stringify(line)")
62
+ })
63
+
64
+ it("emits source that is valid to embed, with no stray template escapes", () => {
65
+ // The generator builds this with a template literal, so an unescaped backtick or `${` silently
66
+ // ends the literal and emits broken code. Cheap to assert, and it has bitten twice.
67
+ expect(ROUTER).not.toContain("\\`")
68
+ expect(ROUTER).not.toContain("\\${")
69
+ expect(ROUTER).toContain('import handler_0 from "')
70
+ })
71
+ })
@@ -291,7 +291,11 @@ describe("scaffold()", () => {
291
291
  expect(existsSync(join(tmpRoot, "functions/.env.local"))).toBe(true)
292
292
  expect(existsSync(join(tmpRoot, "functions/deno.d.ts"))).toBe(true)
293
293
  expect(existsSync(join(tmpRoot, "functions/tsconfig.json"))).toBe(true)
294
- expect(readFileSync(join(tmpRoot, "functions/hello/index.ts"), "utf8")).toContain("Deno.env.get")
294
+ // The scaffold teaches the context, not the environment. It used to read Deno.env.get, which is
295
+ // the process-global delivery that forced the worker to run one invocation at a time.
296
+ const hello = readFileSync(join(tmpRoot, "functions/hello/index.ts"), "utf8")
297
+ expect(hello).toContain("ctx: FunctionContext")
298
+ expect(hello).not.toContain("Deno.env.get")
295
299
  const pkg = readFileSync(join(tmpRoot, "package.json"), "utf8")
296
300
  expect(pkg).toContain("supatype functions serve")
297
301
  })
@@ -329,7 +333,37 @@ describe("scaffold()", () => {
329
333
  expect(config).toContain('provider: "local"')
330
334
  expect(config).toContain("Production storage: external S3")
331
335
  const env = readFileSync(join(tmpRoot, ".env"), "utf8")
332
- expect(env).toContain("local development, MinIO")
336
+ expect(env).toContain("local development, SeaweedFS")
333
337
  expect(env).toContain("production, external bucket")
334
338
  })
339
+
340
+ // The compose generator has had this guard since SeaweedFS replaced MinIO; the .env generator
341
+ // did not, which is the whole reason it kept naming a withdrawn image and a port nothing serves.
342
+ // A scaffolded .env is the first thing a new project reads, so it gets the same guard.
343
+ it("a scaffolded .env never names MinIO or its port", () => {
344
+ for (const storage of ["local", "s3"] as const) {
345
+ scaffold(tmpRoot, {
346
+ ...defaultScaffoldOptions("my-app"),
347
+ storageLocal: storage,
348
+ storageProduction: storage,
349
+ })
350
+ const env = readFileSync(join(tmpRoot, ".env"), "utf8")
351
+ expect(env.toLowerCase()).not.toContain("minio")
352
+ expect(env).not.toContain("9000")
353
+ rmSync(tmpRoot, { recursive: true, force: true })
354
+ mkdirSync(tmpRoot, { recursive: true })
355
+ }
356
+ })
357
+
358
+ // The object store publishes 8333, and the storage service reads S3_ENDPOINT from this file.
359
+ // A local .env pointing anywhere else is a stack that starts and then fails on first upload.
360
+ it("local storage points at the port the object store actually publishes", () => {
361
+ scaffold(tmpRoot, {
362
+ ...defaultScaffoldOptions("my-app"),
363
+ storageLocal: "local",
364
+ storageProduction: "local",
365
+ })
366
+ const env = readFileSync(join(tmpRoot, ".env"), "utf8")
367
+ expect(env).toContain("S3_ENDPOINT=http://localhost:8333")
368
+ })
335
369
  })