@supatype/cli 0.3.1 → 0.3.2

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 (99) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +169 -161
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/api-config-cache.d.ts +20 -0
  5. package/dist/api-config-cache.d.ts.map +1 -1
  6. package/dist/api-config-cache.js +27 -0
  7. package/dist/api-config-cache.js.map +1 -1
  8. package/dist/augmentation-generator.d.ts.map +1 -1
  9. package/dist/augmentation-generator.js +63 -18
  10. package/dist/augmentation-generator.js.map +1 -1
  11. package/dist/cli-version-embedded.js +1 -1
  12. package/dist/client-generator.d.ts +3 -0
  13. package/dist/client-generator.d.ts.map +1 -0
  14. package/dist/client-generator.js +108 -0
  15. package/dist/client-generator.js.map +1 -0
  16. package/dist/commands/functions.d.ts.map +1 -1
  17. package/dist/commands/functions.js +2 -2
  18. package/dist/commands/functions.js.map +1 -1
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +7 -10
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/push.d.ts.map +1 -1
  23. package/dist/commands/push.js +14 -13
  24. package/dist/commands/push.js.map +1 -1
  25. package/dist/commands/update.d.ts.map +1 -1
  26. package/dist/commands/update.js +19 -1
  27. package/dist/commands/update.js.map +1 -1
  28. package/dist/db-port.d.ts +36 -0
  29. package/dist/db-port.d.ts.map +1 -0
  30. package/dist/db-port.js +90 -0
  31. package/dist/db-port.js.map +1 -0
  32. package/dist/dev-compose.d.ts.map +1 -1
  33. package/dist/dev-compose.js +108 -13
  34. package/dist/dev-compose.js.map +1 -1
  35. package/dist/functions-context-refresh.d.ts +4 -0
  36. package/dist/functions-context-refresh.d.ts.map +1 -0
  37. package/dist/functions-context-refresh.js +26 -0
  38. package/dist/functions-context-refresh.js.map +1 -0
  39. package/dist/functions-deno-types.d.ts +9 -0
  40. package/dist/functions-deno-types.d.ts.map +1 -1
  41. package/dist/functions-deno-types.js +21 -1
  42. package/dist/functions-deno-types.js.map +1 -1
  43. package/dist/gitignore.d.ts +3 -1
  44. package/dist/gitignore.d.ts.map +1 -1
  45. package/dist/gitignore.js +28 -6
  46. package/dist/gitignore.js.map +1 -1
  47. package/dist/schema-ast-v2.d.ts.map +1 -1
  48. package/dist/schema-ast-v2.js +17 -8
  49. package/dist/schema-ast-v2.js.map +1 -1
  50. package/dist/self-host-compose.d.ts +10 -2
  51. package/dist/self-host-compose.d.ts.map +1 -1
  52. package/dist/self-host-compose.js +67 -6
  53. package/dist/self-host-compose.js.map +1 -1
  54. package/dist/strict.d.ts +29 -0
  55. package/dist/strict.d.ts.map +1 -0
  56. package/dist/strict.js +37 -0
  57. package/dist/strict.js.map +1 -0
  58. package/dist/studio-dev-server.d.ts +12 -2
  59. package/dist/studio-dev-server.d.ts.map +1 -1
  60. package/dist/studio-dev-server.js +12 -2
  61. package/dist/studio-dev-server.js.map +1 -1
  62. package/dist/type-extractor.d.ts.map +1 -1
  63. package/dist/type-extractor.js +8 -2
  64. package/dist/type-extractor.js.map +1 -1
  65. package/dist/type-generation.d.ts +14 -0
  66. package/dist/type-generation.d.ts.map +1 -1
  67. package/dist/type-generation.js +41 -2
  68. package/dist/type-generation.js.map +1 -1
  69. package/package.json +2 -2
  70. package/src/api-config-cache.ts +30 -0
  71. package/src/augmentation-generator.ts +75 -18
  72. package/src/cli-version-embedded.ts +1 -1
  73. package/src/client-generator.ts +122 -0
  74. package/src/commands/functions.ts +5 -2
  75. package/src/commands/init.ts +10 -10
  76. package/src/commands/push.ts +14 -15
  77. package/src/commands/update.ts +20 -1
  78. package/src/db-port.ts +99 -0
  79. package/src/dev-compose.ts +116 -16
  80. package/src/functions-context-refresh.ts +25 -0
  81. package/src/functions-deno-types.ts +24 -1
  82. package/src/gitignore.ts +31 -10
  83. package/src/schema-ast-v2.ts +17 -8
  84. package/src/self-host-compose.ts +68 -6
  85. package/src/strict.ts +40 -0
  86. package/src/studio-dev-server.ts +12 -2
  87. package/src/type-extractor.ts +8 -2
  88. package/src/type-generation.ts +47 -2
  89. package/tests/ast-derived-outputs.test.ts +92 -0
  90. package/tests/augmentation-generator.test.ts +127 -1
  91. package/tests/client-generator.test.ts +103 -0
  92. package/tests/db-port.test.ts +128 -0
  93. package/tests/external-database-compose.test.ts +8 -1
  94. package/tests/functions-context-refresh.test.ts +86 -0
  95. package/tests/gitignore-secrets.test.ts +90 -0
  96. package/tests/runtime-contract.test.ts +115 -2
  97. package/tests/strict.test.ts +78 -0
  98. package/tests/type-extractor.test.ts +34 -0
  99. package/tsconfig.tsbuildinfo +1 -1
@@ -65,12 +65,58 @@ export function generateClientAugmentation(ast: unknown): string {
65
65
  return lines.join("\n")
66
66
  }
67
67
 
68
+ function toSnakeCase(name: string): string {
69
+ return name.replace(/([A-Z])/g, "_$1").replace(/^_/, "").toLowerCase()
70
+ }
71
+
72
+ /**
73
+ * The column a declared field actually occupies, which is not always the name it was declared under.
74
+ *
75
+ * Only `belongsTo` puts a column on this table, and that column is the foreign key: `speaker_id`
76
+ * holding text, not `speaker` holding the target row. This used to group `relation` with the JSONB
77
+ * kinds, so the augmentation described a column that does not exist while the engine described the
78
+ * one that does, and the two generated files disagreed about the same field. `hasMany`, `hasOne` and
79
+ * `manyToMany` keep their key on the other table, so there is nothing here to describe.
80
+ */
81
+ function resolveColumn(
82
+ name: string,
83
+ meta: Record<string, unknown>,
84
+ ): { column: string; ts: string } | null {
85
+ if (meta["kind"] !== "relation") return { column: name, ts: toTsType(meta) }
86
+ if (meta["cardinality"] !== "belongsTo") return null
87
+
88
+ const annotations = meta["annotations"]
89
+ const declared =
90
+ typeof annotations === "object" && annotations !== null
91
+ ? (annotations as { db?: { foreignKey?: unknown } }).db?.foreignKey
92
+ : undefined
93
+ const column =
94
+ typeof declared === "string" && declared.length > 0 ? declared : `${toSnakeCase(name)}_id`
95
+
96
+ // Nullable unless the relation asked for `NOT NULL`, which is the engine's rule. A fix that made
97
+ // every foreign key non-nullable would be wrong in the other direction just as often.
98
+ return { column, ts: meta["required"] === true ? "string" : "string | null" }
99
+ }
100
+
101
+ /** Declared fields as the columns they become, dropping the ones that are not columns here. */
102
+ function columnsOf(
103
+ fields: Record<string, Record<string, unknown>>,
104
+ ): Array<{ column: string; ts: string; meta: Record<string, unknown> }> {
105
+ return Object.entries(fields)
106
+ .map(([name, meta]) => {
107
+ const resolved = resolveColumn(name, meta)
108
+ return resolved === null ? null : { ...resolved, meta }
109
+ })
110
+ .filter((entry): entry is { column: string; ts: string; meta: Record<string, unknown> } =>
111
+ entry !== null,
112
+ )
113
+ .sort((a, b) => a.column.localeCompare(b.column))
114
+ }
115
+
68
116
  export function generateRowType(fields: Record<string, Record<string, unknown>>): string {
69
- const entries = Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))
70
- if (entries.length === 0) return "Record<string, unknown>"
71
- const body = entries
72
- .map(([name, meta]) => ` ${quoteKey(name)}: ${toTsType(meta)}`)
73
- .join("\n")
117
+ const columns = columnsOf(fields)
118
+ if (columns.length === 0) return "Record<string, unknown>"
119
+ const body = columns.map(({ column, ts }) => ` ${quoteKey(column)}: ${ts}`).join("\n")
74
120
  return `{\n${body}\n}`
75
121
  }
76
122
 
@@ -79,23 +125,21 @@ function insertColumnOptionalOnInsert(meta: Record<string, unknown>): boolean {
79
125
  }
80
126
 
81
127
  export function generateInsertType(fields: Record<string, Record<string, unknown>>): string {
82
- const entries = Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))
83
- if (entries.length === 0) return "Record<string, unknown>"
84
- const body = entries
85
- .map(([name, meta]) => {
128
+ const columns = columnsOf(fields)
129
+ if (columns.length === 0) return "Record<string, unknown>"
130
+ const body = columns
131
+ .map(({ column, ts, meta }) => {
86
132
  const required = meta["required"] === true && !insertColumnOptionalOnInsert(meta)
87
- return ` ${quoteKey(name)}${required ? "" : "?"}: ${toTsType(meta)}`
133
+ return ` ${quoteKey(column)}${required ? "" : "?"}: ${ts}`
88
134
  })
89
135
  .join("\n")
90
136
  return `{\n${body}\n}`
91
137
  }
92
138
 
93
139
  export function generateUpdateType(fields: Record<string, Record<string, unknown>>): string {
94
- const entries = Object.entries(fields).sort(([a], [b]) => a.localeCompare(b))
95
- if (entries.length === 0) return "Record<string, unknown>"
96
- const body = entries
97
- .map(([name, meta]) => ` ${quoteKey(name)}?: ${toTsType(meta)}`)
98
- .join("\n")
140
+ const columns = columnsOf(fields)
141
+ if (columns.length === 0) return "Record<string, unknown>"
142
+ const body = columns.map(({ column, ts }) => ` ${quoteKey(column)}?: ${ts}`).join("\n")
99
143
  return `{\n${body}\n}`
100
144
  }
101
145
 
@@ -119,6 +163,11 @@ function toTsType(meta: Record<string, unknown>): string {
119
163
  case "timestamp":
120
164
  case "datetime":
121
165
  case "money":
166
+ // NUMERIC, and the exact value is the point. A `number` here is the same defect as parsing
167
+ // the wire with `JSON.parse`: `12345678901234567890.1234` has no float representation, so a
168
+ // column declared to hold it would arrive rounded and typed as though it had not been.
169
+ // `@supatype/types` already says so: `Decimal<P, S>` carries `string`.
170
+ case "decimal":
122
171
  case "xml":
123
172
  case "interval":
124
173
  return "string"
@@ -126,7 +175,6 @@ function toTsType(meta: Record<string, unknown>): string {
126
175
  case "smallInt":
127
176
  case "serial":
128
177
  case "float":
129
- case "decimal":
130
178
  return "number"
131
179
  case "bigSerial":
132
180
  case "bigInt":
@@ -139,9 +187,14 @@ function toTsType(meta: Record<string, unknown>): string {
139
187
  case "vector":
140
188
  case "relation":
141
189
  case "array":
190
+ return "Record<string, unknown>"
191
+ // What `storage.upload()` actually produces, plus the bucket it went to. Emitting
192
+ // `Record<string, unknown>` here is why two example screens cast on the good path. A `url`
193
+ // is deliberately absent: a stored one goes stale and hard-codes the storage host, so it is
194
+ // derived at read time instead.
142
195
  case "image":
143
196
  case "file":
144
- return "Record<string, unknown>"
197
+ return "{ bucket: string; path: string }"
145
198
  case "richText":
146
199
  return "(import(\"@supatype/types/lexical\").SerializedEditorState | string)"
147
200
  case "enum": {
@@ -155,7 +208,11 @@ function toTsType(meta: Record<string, unknown>): string {
155
208
  return "unknown"
156
209
  }
157
210
  })()
158
- return required ? base : `${base} | null`
211
+ // A localized column is JSONB holding a locale map, `{"en": ..., "fr": ...}`, not the bare
212
+ // value. `page.title` is exactly this and was typed `string`, so calling a string method on it
213
+ // compiled and then failed against real data.
214
+ const shaped = meta["localized"] === true ? `{ [locale: string]: ${base} }` : base
215
+ return required ? shaped : `${shaped} | null`
159
216
  }
160
217
 
161
218
  function quoteKey(key: string): string {
@@ -9,4 +9,4 @@
9
9
  // Annotated `string`, not left to inference: once a release stamps a value here, the
10
10
  // inferred literal type makes the `!== ""` test in cliPackageVersion() a compile error
11
11
  // (TS2367, no overlap), so the build would only fail during a release.
12
- export const EMBEDDED_CLI_VERSION: string = "0.3.1"
12
+ export const EMBEDDED_CLI_VERSION: string = "0.3.2"
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The client a project imports, generated into its own source tree.
3
+ *
4
+ * One file, written by `supatype push`, that an app imports once:
5
+ *
6
+ * import { createClient } from "./supatype/client"
7
+ *
8
+ * It carries three things that used to arrive by three routes, two of which could fail without
9
+ * saying so.
10
+ *
11
+ * The **module augmentation** was a `.d.ts` that TypeScript found only because the project's
12
+ * tsconfig `include` happened to name the generated directory. Thirteen tsconfig files across the
13
+ * examples named it, one project never named it at all, and in that state `AugmentedTables` falls
14
+ * back to `Record<string, TableDef>`: every table name is accepted, every column is `unknown`, and
15
+ * the build passes. Reaching it by import instead means tsconfig cannot lose it.
16
+ *
17
+ * The **field kinds** were read from `field-kinds.json` on disk, falling back to fetching
18
+ * PostgREST's OpenAPI document. That fallback reconstructs the answer from the live database, so it
19
+ * always succeeds, which made a project that had never generated indistinguishable from one that
20
+ * had. Compiling them in removes both the read and the fallback, and makes absence detectable.
21
+ *
22
+ * And **`createClient`** itself, so the one import is the whole setup. A project that has not
23
+ * generated has no file here to import, so the build fails rather than the app running with a
24
+ * client that knows nothing about its schema.
25
+ */
26
+ import { generateClientAugmentation } from "./augmentation-generator.js"
27
+
28
+ /** The kinds whose values no binary float holds, keyed by the schema kind that produces them. */
29
+ const EXACT_KIND_BY_FIELD_KIND: Readonly<Record<string, "bigint" | "numeric">> = {
30
+ bigInt: "bigint",
31
+ bigSerial: "bigint",
32
+ decimal: "numeric",
33
+ money: "numeric",
34
+ }
35
+
36
+ const FORMAT_VERSION = 1
37
+
38
+ type AstShape = {
39
+ models?: Array<{
40
+ name?: string
41
+ annotations?: Record<string, unknown>
42
+ fields?: Record<string, Record<string, unknown>>
43
+ }>
44
+ }
45
+
46
+ /** The full contents of `supatype/client.ts`. */
47
+ export function generateProjectClient(ast: unknown): string {
48
+ // Normalised first: `push` should not die on a malformed AST while writing a file, and the
49
+ // augmentation generator indexes `.models` without checking.
50
+ const safe = ast !== null && typeof ast === "object" ? ast : { models: [] }
51
+ const augmentation = generateClientAugmentation(safe)
52
+
53
+ return [
54
+ "// Generated by supatype CLI, do not edit manually.",
55
+ "//",
56
+ "// Import the client from here rather than from `@supatype/client`:",
57
+ "//",
58
+ '// import { createClient } from "./supatype/client"',
59
+ "//",
60
+ "// This file carries your schema's types and the columns that need exact handling, so one",
61
+ "// import is the whole setup. Importing `@supatype/client` directly gets a client that knows",
62
+ "// nothing about this project.",
63
+ "",
64
+ 'import { registerFieldKinds } from "@supatype/client"',
65
+ "",
66
+ augmentationBody(augmentation),
67
+ "",
68
+ "// Columns holding values a JSON parser would round: bigInt, decimal and money. Registered at",
69
+ "// import time, so reading one returns what the database holds rather than the nearest double.",
70
+ `registerFieldKinds(${JSON.stringify(fieldKindsDocument(safe), null, 2)})`,
71
+ "",
72
+ 'export * from "@supatype/client"',
73
+ "",
74
+ ].join("\n")
75
+ }
76
+
77
+ /**
78
+ * The augmentation, without the pieces that only made sense in a file of its own.
79
+ *
80
+ * Its header said "generated", which this file says once already, and it ended in `export {}` to
81
+ * make itself a module. This file has imports and exports of its own, so it is one regardless.
82
+ */
83
+ function augmentationBody(augmentation: string): string {
84
+ return augmentation
85
+ .replace(/^\/\/ Generated by supatype CLI[^\n]*\n+/, "")
86
+ .replace(/\n*export \{\}\s*$/, "")
87
+ .trimEnd()
88
+ }
89
+
90
+ /** The exact-value columns of every table, as the registry expects them. */
91
+ function fieldKindsDocument(ast: unknown): { version: number; tables: Record<string, unknown> } {
92
+ const tables: Record<string, Record<string, string>> = {}
93
+
94
+ for (const model of readModels(ast)) {
95
+ const exact: Record<string, string> = {}
96
+ for (const [column, meta] of Object.entries(model.fields ?? {})) {
97
+ const kind = typeof meta["kind"] === "string" ? meta["kind"] : ""
98
+ const exactKind = EXACT_KIND_BY_FIELD_KIND[kind]
99
+ if (exactKind !== undefined) exact[column] = exactKind
100
+ }
101
+ if (Object.keys(exact).length > 0) {
102
+ tables[resolveTableName(model)] = exact
103
+ }
104
+ }
105
+
106
+ return { version: FORMAT_VERSION, tables }
107
+ }
108
+
109
+ function resolveTableName(model: { name?: string; annotations?: Record<string, unknown> }): string {
110
+ const db = model.annotations?.["db"]
111
+ if (db !== null && typeof db === "object") {
112
+ const named = (db as { tableName?: unknown }).tableName
113
+ if (typeof named === "string" && named !== "") return named
114
+ }
115
+ return model.name ?? ""
116
+ }
117
+
118
+ function readModels(ast: unknown): NonNullable<AstShape["models"]> {
119
+ if (ast === null || typeof ast !== "object") return []
120
+ const models = (ast as AstShape).models
121
+ return Array.isArray(models) ? models : []
122
+ }
@@ -25,7 +25,10 @@ import {
25
25
  import { loadProjectLink } from "../link.js"
26
26
  import { resolveTarget } from "../resolve-target.js"
27
27
  import { targetFetch } from "../target-client.js"
28
- import { ensureFunctionsDenoTypes } from "../functions-deno-types.js"
28
+ import {
29
+ ensureFunctionsDenoTypes,
30
+ sharedFunctionsReadmeSource,
31
+ } from "../functions-deno-types.js"
29
32
  import { error, info, plain } from "../ui/messages.js"
30
33
  import { nextSteps } from "../ui/next-steps.js"
31
34
 
@@ -173,7 +176,7 @@ export default async function handler(req: Request, ctx: FunctionContext): Promi
173
176
  mkdirSync(sharedDir, { recursive: true })
174
177
  writeFileSync(
175
178
  join(sharedDir, "README.md"),
176
- "# Shared Code\n\nFiles in `_shared/` are available to all functions via relative imports.\nThis directory is not deployed as a function.\n\nExample: `import { sendEmail } from '../_shared/email.ts'`\n",
179
+ sharedFunctionsReadmeSource(),
177
180
  "utf8",
178
181
  )
179
182
  }
@@ -14,7 +14,10 @@ import {
14
14
  ensureComponentBinaries,
15
15
  reportComponentBinaryFailures,
16
16
  } from "../ensure-component-binaries.js"
17
- import { ensureFunctionsDenoTypes } from "../functions-deno-types.js"
17
+ import {
18
+ ensureFunctionsDenoTypes,
19
+ sharedFunctionsReadmeSource,
20
+ } from "../functions-deno-types.js"
18
21
  import { mergeSupatypePackageJson } from "../init-package-json.js"
19
22
  import { cliPackageVersion } from "../cli-package-version.js"
20
23
  import {
@@ -23,6 +26,7 @@ import {
23
26
  type InitDependencyVersions,
24
27
  } from "../init-dependency-versions.js"
25
28
  import { detectProjectSetup, type DetectedProjectSetup } from "../init-project-detect.js"
29
+ import { SUPATYPE_GITIGNORE_PATHS } from "../gitignore.js"
26
30
 
27
31
  // ─── Options model ─────────────────────────────────────────────────────────--
28
32
 
@@ -789,7 +793,7 @@ function scaffoldHelloFunction(
789
793
  ): void {
790
794
  write("functions/hello/index.ts", helloFunctionTemplate())
791
795
  if (!existsSync(join(dir, "functions/_shared/README.md"))) {
792
- write("functions/_shared/README.md", sharedFunctionsReadme())
796
+ write("functions/_shared/README.md", sharedFunctionsReadmeSource())
793
797
  }
794
798
  if (!existsSync(join(dir, "functions/.env.local"))) {
795
799
  write("functions/.env.local", functionsEnvLocalTemplate())
@@ -1396,22 +1400,18 @@ export default async function handler(req: Request, ctx: FunctionContext): Promi
1396
1400
  `
1397
1401
  }
1398
1402
 
1399
- function sharedFunctionsReadme(): string {
1400
- return "# Shared Code\n\nFiles in `_shared/` are available to all functions via relative imports.\nThis directory is not deployed as a function.\n\nExample: `import { sendEmail } from '../_shared/email.ts'`\n"
1401
- }
1402
1403
 
1403
1404
  function functionsEnvLocalTemplate(): string {
1404
1405
  return "# Local environment variables for edge functions\n# These are NOT committed to git\n# Set production env vars via: npx supatype functions env set KEY=value\n"
1405
1406
  }
1406
1407
 
1407
1408
  function gitignoreTemplate(): string {
1408
- return `.env
1409
+ // Supatype's own paths come from `SUPATYPE_GITIGNORE_PATHS` rather than being written out again
1410
+ // here. This template and that list were two copies of the same thing and had already diverged;
1411
+ // the copy that drifts is found when a key reaches a public repository.
1412
+ return `${SUPATYPE_GITIGNORE_PATHS.join("\n")}
1409
1413
  node_modules/
1410
1414
  dist/
1411
- .supatype/
1412
- supatype.local.config.ts
1413
- supatype.local.config.js
1414
- supatype.local.config.mjs
1415
1415
  # Generated by supatype push (legacy paths, prefer output.types in config)
1416
1416
  src/types/supatype.d.ts
1417
1417
  src/lib/supatype.ts
@@ -33,7 +33,8 @@ import type { ExtractedSchemaAstV2 } from "../schema-ast-v2.js"
33
33
  import { ensureFirstAdminUser } from "./admin.js"
34
34
  import { withAdminRoles } from "../studio-admin-roles.js"
35
35
  import { restoreSystemRelationTargets } from "../restore-system-relation-targets.js"
36
- import { freeTierCacheNote, seedApiConfigCache } from "../api-config-cache.js"
36
+ import { cacheSeedingNotes, freeTierCacheNote } from "../api-config-cache.js"
37
+ import { refreshFunctionsContext } from "../functions-context-refresh.js"
37
38
  import type { SupatypeProjectConfig } from "../project-config.js"
38
39
  import {
39
40
  resolveTarget,
@@ -265,20 +266,7 @@ async function deployHooksToTarget(
265
266
  * Never an error, and never a rewrite of an entry that already exists. See `seedApiConfigCache`.
266
267
  */
267
268
  function reportCacheSeeding(cwd: string, ast: unknown): void {
268
- const result = seedApiConfigCache(cwd, ast)
269
- if (result === null) return
270
-
271
- if (result.seeded.length > 0) {
272
- info(`Server cache enabled for ${result.seeded.join(", ")} in .supatype/api-config.json`)
273
- }
274
- if (result.ttlIsOff) {
275
- // A note rather than a fix. Zero is an off switch someone may have chosen, and a push that
276
- // turned caching on project-wide would be overriding a decision rather than filling in a blank.
277
- info(
278
- `${result.declared.length} table(s) declare a cache, but cache_max_ttl is 0 — nothing is ` +
279
- `cached until it is set, under API → REST → Settings or in .supatype/api-config.json.`,
280
- )
281
- }
269
+ for (const note of cacheSeedingNotes(cwd, ast)) info(note)
282
270
  }
283
271
 
284
272
  /** The generated adapter, flattened to the name handlers were rewritten to import. */
@@ -294,6 +282,17 @@ async function generateTypesLocal(ast: unknown, config: SupatypeProjectConfig):
294
282
  const cwd = process.cwd()
295
283
  const hooksPath = writeHooksModule(cwd, hooksPathFromProject(config, cwd), ast)
296
284
  if (hooksPath !== null) info(`Hook handler types written to ${hooksPath}`)
285
+
286
+ // `_shared/context.ts` too, for the same reason the hook module is here.
287
+ //
288
+ // It was written by `init` and `functions new` and by nothing else, so a project that predates
289
+ // the two-argument handler contract never received it: its functions had no `FunctionContext` to
290
+ // import, while the file itself claims "Regenerated by the CLI. Edits are overwritten." That
291
+ // claim was only true if you scaffolded again. `tests/integration` had four functions and no
292
+ // context module at all.
293
+ if (refreshFunctionsContext(cwd, config)) {
294
+ info("Function context type written to functions/_shared/context.ts")
295
+ }
297
296
  // The server watches this file, so a changed hook takes effect without a restart.
298
297
  if (syncManifestHooks(cwd, ast)) info("Hook map written to .supatype/manifest.json")
299
298
  // The row cache is configured at postmaster start, so this is the one cache setting a push
@@ -23,6 +23,21 @@ function resolveConfigFile(cwd: string): string | null {
23
23
  return null
24
24
  }
25
25
 
26
+ /**
27
+ * New images alone are not a finished upgrade.
28
+ *
29
+ * Components carry schema with them: grants, policies and the rest are generated by the engine and
30
+ * applied by `push`, not baked into an image. Pulling a newer storage service onto a database that
31
+ * predates its grants leaves every private bucket read refused, and the only clue is in the
32
+ * service's log. The remedy is one command, so it is named here rather than left to be discovered.
33
+ */
34
+ function pushReminder(): void {
35
+ plain("")
36
+ info("Run `supatype push` to apply schema changes the new components expect.")
37
+ plain(" Components ship code; grants, policies and functions come from your schema.")
38
+ plain(" Skipping it can leave storage reads and other rules refused until it runs.")
39
+ }
40
+
26
41
  export function registerUpdate(program: Command): void {
27
42
  program
28
43
  .command("update")
@@ -37,7 +52,9 @@ export function registerUpdate(program: Command): void {
37
52
 
38
53
  if (provider === "docker") {
39
54
  if (opts.check) {
40
- info("Docker provider: run without --check to pull compose images (supatype self-host compose pull).")
55
+ // There is no `self-host compose pull` subcommand; the earlier text named one and a reader
56
+ // following it got "unknown command". `update` is what pulls.
57
+ info("Docker provider: run `supatype update` without --check to pull the compose images.")
41
58
  return
42
59
  }
43
60
  const paths = writeSelfHostCompose(cwd, config, { devLocal: true })
@@ -54,6 +71,7 @@ export function registerUpdate(program: Command): void {
54
71
  exitComposeFailed(status, "Could not pull Compose images.", brand)
55
72
  }
56
73
  info("Compose images updated.")
74
+ pushReminder()
57
75
  return
58
76
  }
59
77
 
@@ -149,5 +167,6 @@ export function registerUpdate(program: Command): void {
149
167
 
150
168
  writeFileSync(configPath, text, "utf8")
151
169
  info(`${basename(configPath)} updated.`)
170
+ pushReminder()
152
171
  })
153
172
  }
package/src/db-port.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Keep `DATABASE_URL` pointing at the port Postgres is actually published on.
3
+ *
4
+ * `SUPATYPE_DB_PORT` moves the host side of the compose port mapping, so a second project on one
5
+ * machine can avoid a clash. `DATABASE_URL` is what every host-side script uses: seeds, one-off
6
+ * psql, `supatype admin create-user --connection`. `init` writes the URL with 5432 in it and
7
+ * nothing has ever reconciled the two.
8
+ *
9
+ * The result is a project that starts cleanly, serves every request, and fails the first time
10
+ * someone runs `npm run seed`:
11
+ *
12
+ * AggregateError [ECONNREFUSED]: connect ECONNREFUSED 127.0.0.1:5432
13
+ *
14
+ * with the database listening on 5433 and nothing connecting the error to the port variable the
15
+ * person set half an hour earlier. The generated `.env` even carries a comment conceding the
16
+ * assumption: "Self-host compose uses the same DATABASE_URL when Postgres is published on
17
+ * localhost:5432."
18
+ *
19
+ * `SUPATYPE_DB_PORT` is the source of truth, because it is what compose binds. The URL follows it.
20
+ */
21
+ import { readEnvFile, upsertEnvFile } from "./env-file.js"
22
+
23
+ /** The `.env` key that moves the published Postgres port for a self-host stack. */
24
+ export const DB_PORT_ENV = "SUPATYPE_DB_PORT"
25
+
26
+ /**
27
+ * The `.env` key that moves it for a dev-local stack, and which takes precedence.
28
+ *
29
+ * Two variables, two deployment shapes: an ordinary self-host stack publishes Postgres at
30
+ * `SUPATYPE_DB_PORT`, and a project with `overrides.engine` publishes it at
31
+ * `SUPATYPE_DEV_DB_PORT` so the host-side engine binary can reach it. Its presence is what marks
32
+ * the second shape, so it wins where both are set.
33
+ *
34
+ * Learned the hard way. The first cut of this file read only `SUPATYPE_DB_PORT`, so on a project
35
+ * with `SUPATYPE_DEV_DB_PORT=54330` it rewrote a correct `DATABASE_URL` to 5432, where nothing was
36
+ * listening. It fixed the bug it was written for and reintroduced it through the other door, which
37
+ * is the exact failure this file exists to prevent.
38
+ */
39
+ export const DEV_DB_PORT_ENV = "SUPATYPE_DEV_DB_PORT"
40
+
41
+ /** What compose binds when neither variable is set, and what `init` writes into the URL. */
42
+ export const DEFAULT_DB_PORT = 5432
43
+
44
+ /** Hosts whose port is ours to correct. Anything else is somebody else's database. */
45
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"])
46
+
47
+ /** The port Postgres is published on for this project, from `.env`. */
48
+ export function publishedDbPort(env: Record<string, string>): number {
49
+ // Dev-local first: its presence is what says this project publishes for a host-side engine.
50
+ for (const key of [DEV_DB_PORT_ENV, DB_PORT_ENV]) {
51
+ const raw = (env[key] ?? "").trim()
52
+ if (raw === "") continue
53
+ const port = Number(raw)
54
+ // A non-numeric or out-of-range value is the operator's to fix, and compose reports it far
55
+ // better than we can. Falling through keeps this from inventing a port nothing listens on.
56
+ if (Number.isInteger(port) && port > 0 && port < 65536) return port
57
+ }
58
+ return DEFAULT_DB_PORT
59
+ }
60
+
61
+ /** What changed, for the caller to report. */
62
+ export interface DbPortSync {
63
+ from: string
64
+ to: string
65
+ }
66
+
67
+ /**
68
+ * Rewrite `DATABASE_URL`'s port to match `SUPATYPE_DB_PORT`, and report it.
69
+ *
70
+ * Returns null when there is nothing to do, which is the common case.
71
+ *
72
+ * Only touches a URL pointing at this machine. A project whose `DATABASE_URL` names a managed
73
+ * Postgres elsewhere has a port that is nothing to do with our compose mapping, and rewriting it
74
+ * would repoint the whole project at a database that does not exist.
75
+ */
76
+ export function syncDatabaseUrlPort(cwd: string): DbPortSync | null {
77
+ const env = readEnvFile(cwd)
78
+ const raw = (env["DATABASE_URL"] ?? "").trim()
79
+ if (raw === "") return null
80
+
81
+ let url: URL
82
+ try {
83
+ url = new URL(raw)
84
+ } catch {
85
+ // Malformed, so not ours to rewrite: a partial edit here would turn a typo into a URL that
86
+ // parses and points somewhere wrong, which is harder to spot than the typo.
87
+ return null
88
+ }
89
+
90
+ if (!LOCAL_HOSTS.has(url.hostname)) return null
91
+
92
+ const wanted = String(publishedDbPort(env))
93
+ const current = url.port === "" ? String(DEFAULT_DB_PORT) : url.port
94
+ if (current === wanted) return null
95
+
96
+ url.port = wanted
97
+ upsertEnvFile(cwd, { DATABASE_URL: url.toString() })
98
+ return { from: current, to: wanted }
99
+ }