@supatype/cli 0.3.1 → 0.3.3

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 (148) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +168 -158
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +77 -0
  5. package/dist/api-config-cache.d.ts +20 -0
  6. package/dist/api-config-cache.d.ts.map +1 -1
  7. package/dist/api-config-cache.js +27 -0
  8. package/dist/api-config-cache.js.map +1 -1
  9. package/dist/augmentation-generator.d.ts.map +1 -1
  10. package/dist/augmentation-generator.js +126 -26
  11. package/dist/augmentation-generator.js.map +1 -1
  12. package/dist/cli-version-embedded.js +1 -1
  13. package/dist/client-generator.d.ts +3 -0
  14. package/dist/client-generator.d.ts.map +1 -0
  15. package/dist/client-generator.js +108 -0
  16. package/dist/client-generator.js.map +1 -0
  17. package/dist/commands/functions.d.ts.map +1 -1
  18. package/dist/commands/functions.js +2 -2
  19. package/dist/commands/functions.js.map +1 -1
  20. package/dist/commands/generate.d.ts.map +1 -1
  21. package/dist/commands/generate.js +3 -3
  22. package/dist/commands/generate.js.map +1 -1
  23. package/dist/commands/init.d.ts.map +1 -1
  24. package/dist/commands/init.js +36 -29
  25. package/dist/commands/init.js.map +1 -1
  26. package/dist/commands/push.d.ts.map +1 -1
  27. package/dist/commands/push.js +14 -13
  28. package/dist/commands/push.js.map +1 -1
  29. package/dist/commands/seed.d.ts +27 -1
  30. package/dist/commands/seed.d.ts.map +1 -1
  31. package/dist/commands/seed.js +206 -33
  32. package/dist/commands/seed.js.map +1 -1
  33. package/dist/commands/update.d.ts.map +1 -1
  34. package/dist/commands/update.js +19 -1
  35. package/dist/commands/update.js.map +1 -1
  36. package/dist/db-port.d.ts +36 -0
  37. package/dist/db-port.d.ts.map +1 -0
  38. package/dist/db-port.js +90 -0
  39. package/dist/db-port.js.map +1 -0
  40. package/dist/dev-compose.d.ts.map +1 -1
  41. package/dist/dev-compose.js +108 -13
  42. package/dist/dev-compose.js.map +1 -1
  43. package/dist/engine-client.d.ts +1 -0
  44. package/dist/engine-client.d.ts.map +1 -1
  45. package/dist/engine-client.js +47 -2
  46. package/dist/engine-client.js.map +1 -1
  47. package/dist/engine-floor.d.ts +27 -4
  48. package/dist/engine-floor.d.ts.map +1 -1
  49. package/dist/engine-floor.js +90 -9
  50. package/dist/engine-floor.js.map +1 -1
  51. package/dist/functions-context-refresh.d.ts +4 -0
  52. package/dist/functions-context-refresh.d.ts.map +1 -0
  53. package/dist/functions-context-refresh.js +26 -0
  54. package/dist/functions-context-refresh.js.map +1 -0
  55. package/dist/functions-deno-types.d.ts +9 -0
  56. package/dist/functions-deno-types.d.ts.map +1 -1
  57. package/dist/functions-deno-types.js +21 -1
  58. package/dist/functions-deno-types.js.map +1 -1
  59. package/dist/gitignore.d.ts +3 -1
  60. package/dist/gitignore.d.ts.map +1 -1
  61. package/dist/gitignore.js +35 -6
  62. package/dist/gitignore.js.map +1 -1
  63. package/dist/hooks-generator.d.ts.map +1 -1
  64. package/dist/hooks-generator.js +18 -3
  65. package/dist/hooks-generator.js.map +1 -1
  66. package/dist/schema-ast-v2.d.ts.map +1 -1
  67. package/dist/schema-ast-v2.js +17 -8
  68. package/dist/schema-ast-v2.js.map +1 -1
  69. package/dist/seed-connection.d.ts +60 -0
  70. package/dist/seed-connection.d.ts.map +1 -0
  71. package/dist/seed-connection.js +90 -0
  72. package/dist/seed-connection.js.map +1 -0
  73. package/dist/seed-ir.d.ts +66 -0
  74. package/dist/seed-ir.d.ts.map +1 -0
  75. package/dist/seed-ir.js +265 -0
  76. package/dist/seed-ir.js.map +1 -0
  77. package/dist/seed-runner.d.ts +44 -0
  78. package/dist/seed-runner.d.ts.map +1 -0
  79. package/dist/seed-runner.js +83 -0
  80. package/dist/seed-runner.js.map +1 -0
  81. package/dist/seed.d.ts +345 -0
  82. package/dist/seed.d.ts.map +1 -1
  83. package/dist/seed.js +138 -0
  84. package/dist/seed.js.map +1 -1
  85. package/dist/self-host-compose.d.ts +10 -2
  86. package/dist/self-host-compose.d.ts.map +1 -1
  87. package/dist/self-host-compose.js +73 -6
  88. package/dist/self-host-compose.js.map +1 -1
  89. package/dist/strict.d.ts +29 -0
  90. package/dist/strict.d.ts.map +1 -0
  91. package/dist/strict.js +37 -0
  92. package/dist/strict.js.map +1 -0
  93. package/dist/studio-dev-server.d.ts +12 -2
  94. package/dist/studio-dev-server.d.ts.map +1 -1
  95. package/dist/studio-dev-server.js +12 -2
  96. package/dist/studio-dev-server.js.map +1 -1
  97. package/dist/type-extractor.d.ts.map +1 -1
  98. package/dist/type-extractor.js +81 -2
  99. package/dist/type-extractor.js.map +1 -1
  100. package/dist/type-generation.d.ts +43 -0
  101. package/dist/type-generation.d.ts.map +1 -1
  102. package/dist/type-generation.js +93 -2
  103. package/dist/type-generation.js.map +1 -1
  104. package/package.json +2 -2
  105. package/src/api-config-cache.ts +30 -0
  106. package/src/augmentation-generator.ts +142 -26
  107. package/src/cli-version-embedded.ts +1 -1
  108. package/src/client-generator.ts +122 -0
  109. package/src/commands/functions.ts +5 -2
  110. package/src/commands/generate.ts +7 -3
  111. package/src/commands/init.ts +41 -31
  112. package/src/commands/push.ts +14 -15
  113. package/src/commands/seed.ts +294 -44
  114. package/src/commands/update.ts +20 -1
  115. package/src/db-port.ts +99 -0
  116. package/src/dev-compose.ts +116 -16
  117. package/src/engine-client.ts +52 -3
  118. package/src/engine-floor.ts +100 -9
  119. package/src/functions-context-refresh.ts +25 -0
  120. package/src/functions-deno-types.ts +24 -1
  121. package/src/gitignore.ts +38 -10
  122. package/src/hooks-generator.ts +21 -6
  123. package/src/schema-ast-v2.ts +17 -8
  124. package/src/seed-connection.ts +146 -0
  125. package/src/seed-ir.ts +311 -0
  126. package/src/seed-runner.ts +109 -0
  127. package/src/seed.ts +408 -0
  128. package/src/self-host-compose.ts +74 -6
  129. package/src/strict.ts +40 -0
  130. package/src/studio-dev-server.ts +12 -2
  131. package/src/type-extractor.ts +109 -2
  132. package/src/type-generation.ts +111 -2
  133. package/tests/access-composition.test.ts +123 -0
  134. package/tests/ast-derived-outputs.test.ts +92 -0
  135. package/tests/augmentation-generator.test.ts +273 -7
  136. package/tests/client-generator.test.ts +103 -0
  137. package/tests/db-port.test.ts +128 -0
  138. package/tests/engine-floor.test.ts +93 -0
  139. package/tests/external-database-compose.test.ts +8 -1
  140. package/tests/fixtures/seed_golden_ir.json +186 -0
  141. package/tests/functions-context-refresh.test.ts +86 -0
  142. package/tests/gitignore-secrets.test.ts +90 -0
  143. package/tests/runtime-contract.test.ts +146 -8
  144. package/tests/seed-connection.test.ts +158 -0
  145. package/tests/seed-ir.test.ts +436 -0
  146. package/tests/strict.test.ts +78 -0
  147. package/tests/type-extractor.test.ts +34 -0
  148. package/tsconfig.tsbuildinfo +1 -1
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { generateClientAugmentation } from "../src/augmentation-generator.js"
2
+ import { generateClientAugmentation, generateRowType } from "../src/augmentation-generator.js"
3
3
 
4
4
  describe("generateClientAugmentation", () => {
5
- it("emits deterministic output independent of model order", () => {
5
+ it("emits models in the order the schema declares them", () => {
6
6
  const astA = {
7
7
  models: [
8
8
  {
@@ -32,9 +32,13 @@ describe("generateClientAugmentation", () => {
32
32
  ],
33
33
  }
34
34
 
35
- expect(generateClientAugmentation(astA)).toEqual(generateClientAugmentation(astB))
36
- expect(generateClientAugmentation(astA)).toContain("post:")
37
- expect(generateClientAugmentation(astA)).toContain("comment:")
35
+ // The AST keeps the order `schema/index.ts` was written in, so the generated file does
36
+ // too: alphabetising threw away an order the author chose and the source of truth kept.
37
+ const a = generateClientAugmentation(astA)
38
+ const b = generateClientAugmentation(astB)
39
+ expect(a.indexOf("post:")).toBeLessThan(a.indexOf("comment:"))
40
+ expect(b.indexOf("comment:")).toBeLessThan(b.indexOf("post:"))
41
+ expect(a).not.toEqual(b)
38
42
  })
39
43
 
40
44
  it("resolves tableName from AST v2 annotations.db.tableName", () => {
@@ -80,7 +84,7 @@ describe("generateClientAugmentation", () => {
80
84
  expect(insertOnly).not.toContain("name?:")
81
85
  })
82
86
 
83
- it("types richText fields as SerializedEditorState", () => {
87
+ it("types richText fields as the Lexical document they hold", () => {
84
88
  const ast = {
85
89
  models: [
86
90
  {
@@ -93,10 +97,40 @@ describe("generateClientAugmentation", () => {
93
97
  ],
94
98
  }
95
99
  const out = generateClientAugmentation(ast)
96
- expect(out).toContain('import("@supatype/types/lexical").SerializedEditorState')
100
+ expect(out).toContain('import type { SerializedEditorState } from "@supatype/types/lexical"')
101
+ expect(out).toContain("export type RichText = SerializedEditorState")
102
+ expect(out).toContain("body: RichText")
97
103
  expect(out).not.toMatch(/body: Record<string, unknown>/)
98
104
  })
99
105
 
106
+ it("widens a localized richText column inside the locale map, not around it", () => {
107
+ // The column holds one document per locale and the trigger normalises each of them, so what
108
+ // accepts prose is the locale's value. `{ [locale: string]: RichText } | string` would say a
109
+ // bare string is a valid write, and the trigger would store it as a single document where a
110
+ // locale map belongs, which the Row type says cannot happen.
111
+ const ast = {
112
+ models: [
113
+ {
114
+ tableName: "page",
115
+ fields: {
116
+ id: { kind: "uuid", pgType: "UUID", required: true, primaryKey: true, default: { kind: "genRandomUuid" } },
117
+ title: { kind: "richText", pgType: "JSONB", required: false, localized: true },
118
+ body: { kind: "richText", pgType: "JSONB", required: false },
119
+ },
120
+ },
121
+ ],
122
+ }
123
+ const out = generateClientAugmentation(ast)
124
+
125
+ expect(out).toContain("title: { [locale: string]: RichText } | null")
126
+ expect(out).toContain("title?: { [locale: string]: RichText | string } | null")
127
+ expect(out).not.toContain("{ [locale: string]: RichText } | string")
128
+
129
+ // The plain column keeps the widening at the top, so one cannot be flattened into the other.
130
+ expect(out).toContain("body: RichText | null")
131
+ expect(out).toContain("body?: RichText | string | null")
132
+ })
133
+
100
134
  it("uses a field's declared tsType instead of collapsing JSONB to an opaque object", () => {
101
135
  // `Currency<"USD">` and `Code<"sql">` are stored as JSONB because each carries two values. If
102
136
  // the generated row typed them as `Record<string, unknown>`, a caller reading `price.amount`
@@ -125,3 +159,235 @@ describe("generateClientAugmentation", () => {
125
159
  expect(out).toContain("notes: Record<string, unknown> | null")
126
160
  })
127
161
  })
162
+
163
+ describe("exact-value numeric kinds", () => {
164
+ /**
165
+ * A column whose value does not survive an IEEE-754 double must not be typed as one.
166
+ *
167
+ * `decimal` used to emit `number` here while `@supatype/types` declared `Decimal<P, S>` as
168
+ * `string` and the engine emitted `string`, so the ambient client type contradicted both and
169
+ * told a caller a rounded value was exact.
170
+ */
171
+ it("types money, decimal and bigInt so the exact value survives", () => {
172
+ const out = generateClientAugmentation({
173
+ models: [
174
+ {
175
+ name: "Invoice",
176
+ annotations: { db: { tableName: "invoice", indexes: [] } },
177
+ fields: {
178
+ total: { kind: "money", required: true },
179
+ rate: { kind: "decimal", required: true },
180
+ ref: { kind: "bigInt", required: true },
181
+ qty: { kind: "integer", required: true },
182
+ },
183
+ },
184
+ ],
185
+ })
186
+
187
+ expect(out).toContain("total: string")
188
+ expect(out).toContain("rate: string")
189
+ expect(out).toContain("ref: bigint")
190
+ // The kinds that genuinely fit a double are untouched.
191
+ expect(out).toContain("qty: number")
192
+ })
193
+ })
194
+
195
+ describe("relation fields", () => {
196
+ const talk = {
197
+ models: [
198
+ {
199
+ name: "Talk",
200
+ annotations: { db: { tableName: "talk", indexes: [] } },
201
+ fields: {
202
+ title: { kind: "text", required: true },
203
+ speaker: {
204
+ kind: "relation",
205
+ cardinality: "belongsTo",
206
+ target: "Speaker",
207
+ annotations: { db: { foreignKey: "speaker_id" } },
208
+ },
209
+ venue: {
210
+ kind: "relation",
211
+ cardinality: "belongsTo",
212
+ target: "Room",
213
+ required: true,
214
+ annotations: { db: { foreignKey: "room_id" } },
215
+ },
216
+ authUser: { kind: "relation", cardinality: "belongsTo", target: "User" },
217
+ slots: { kind: "relation", cardinality: "hasMany", target: "Slot" },
218
+ },
219
+ },
220
+ ],
221
+ }
222
+
223
+ /**
224
+ * A `belongsTo` is a foreign key column, not an embedded object.
225
+ *
226
+ * The generator used to group `relation` with geo/vector/image and emit
227
+ * `Record<string, unknown>` under the *declared* name, so the augmentation claimed a column
228
+ * named `speaker` holding an object while Postgres and the engine both had `speaker_id`
229
+ * holding text. Wrong name and wrong type on the same column.
230
+ */
231
+ it("emits the foreign key column, not the declared relation name", () => {
232
+ const out = generateClientAugmentation(talk)
233
+ expect(out).toContain("speaker_id: string | null")
234
+ expect(out).not.toContain("speaker: Record<string, unknown>")
235
+ })
236
+
237
+ it("keeps a required relation non-nullable, matching the engine", () => {
238
+ expect(generateClientAugmentation(talk)).toContain("room_id: string\n")
239
+ })
240
+
241
+ /** No `foreignKey` annotation, so the column name falls back to the same convention. */
242
+ it("derives the column name when the AST carries no foreignKey", () => {
243
+ expect(generateClientAugmentation(talk)).toContain("auth_user_id: string | null")
244
+ })
245
+
246
+ /** The key lives on the other table, so this side has no column to describe. */
247
+ it("omits hasMany, which is not a column on this table", () => {
248
+ const out = generateClientAugmentation(talk)
249
+ expect(out).not.toContain("slots")
250
+ })
251
+ })
252
+
253
+ describe("column shapes that disagreed with the database", () => {
254
+ it("types a localized column as a locale map", () => {
255
+ // `page.title` is jsonb holding {"en": "...", "fr": "..."} and this said `string`, so
256
+ // `page.title.toUpperCase()` compiled and then failed in the browser.
257
+ const row = generateRowType({
258
+ title: { kind: "text", required: true, localized: true },
259
+ })
260
+ expect(row).toContain("title: { [locale: string]: string }")
261
+ })
262
+
263
+ it("leaves a non-localized column of the same kind alone", () => {
264
+ // The control: a fix that wrapped every text column would pass the assertion above.
265
+ const row = generateRowType({ slug: { kind: "text", required: true } })
266
+ expect(row).toContain("slug: string")
267
+ expect(row).not.toContain("locale")
268
+ })
269
+
270
+ it("types an image as what storage.upload actually produces", () => {
271
+ // Was `Record<string, unknown>`, which is why the examples cast on the good path. Named
272
+ // rather than written out, so this file and `types/database.ts` say it the same way.
273
+ const row = generateRowType({
274
+ headshot: { kind: "image", required: false, bucket: "speaker-headshots" },
275
+ })
276
+ expect(row).toContain("headshot: StorageReference | null")
277
+ })
278
+
279
+ it("types a file the same way", () => {
280
+ const row = generateRowType({ attachment: { kind: "file", required: true } })
281
+ expect(row).toContain("attachment: StorageReference")
282
+ })
283
+
284
+ it("does not store a url, because a stored one goes stale", () => {
285
+ const row = generateRowType({ headshot: { kind: "image", required: true } })
286
+ expect(row).not.toContain("url")
287
+ })
288
+ })
289
+
290
+ /**
291
+ * What this file has to agree with.
292
+ *
293
+ * `types/database.ts` and `supatype/generated/index.d.ts` describe the same columns of the
294
+ * same tables, and are written by two different generators: the engine's and this one. When
295
+ * they were compared column by column for a real schema, three of them disagreed, and in
296
+ * every case this side was the one that was wrong. Each is pinned below.
297
+ *
298
+ * The comparison itself is not automated here, because it needs an engine binary. What is
299
+ * automated is the property each disagreement came down to.
300
+ */
301
+ describe("agreement with the engine's generator", () => {
302
+ const ast = {
303
+ models: [
304
+ {
305
+ name: "Event",
306
+ fields: {
307
+ id: { kind: "uuid", required: true, primaryKey: true },
308
+ title: { kind: "text", required: true },
309
+ slug: { kind: "slug", required: true },
310
+ description: { kind: "richText", required: false },
311
+ cover_image: { kind: "image", required: false },
312
+ },
313
+ annotations: { db: { tableName: "event", indexes: [] } },
314
+ },
315
+ ],
316
+ }
317
+
318
+ /**
319
+ * A slug is filled by a trigger the engine emits, so an insert need not supply one. This
320
+ * side demanded it, which asked for a value the database was about to overwrite.
321
+ */
322
+ it("does not demand a slug on insert", () => {
323
+ const out = generateClientAugmentation(ast)
324
+ const insert = out.split("Insert: {")[1]?.split("}")[0] ?? ""
325
+ expect(insert).toContain("slug?:")
326
+ expect(insert).not.toContain("slug: ")
327
+ })
328
+
329
+ /**
330
+ * A rich-text column holds a Lexical document. `| string` said it might hold a bare string
331
+ * too, so this file and `types/database.ts` disagreed about the same column.
332
+ */
333
+ it("types rich text as the document it holds, and nothing looser", () => {
334
+ const out = generateClientAugmentation(ast)
335
+ expect(out).toContain("description: RichText | null")
336
+ expect(out).not.toContain("SerializedEditorState | string")
337
+ })
338
+
339
+ /**
340
+ * The three shapes differ by more than optionality here, which they do nowhere else. The
341
+ * engine emits a trigger that turns a plain string into a document on the way in, so a read
342
+ * is always a document and a write may be prose.
343
+ */
344
+ it("types rich text as a document to read and prose to write", () => {
345
+ const out = generateClientAugmentation(ast)
346
+ const block = (name: string): string =>
347
+ out.split(`${name}: {`)[1]?.split(" }")[0] ?? ""
348
+ expect(block("Row")).toContain("description: RichText | null")
349
+ expect(block("Insert")).toContain("description?: RichText | string | null")
350
+ expect(block("Update")).toContain("description?: RichText | string | null")
351
+ })
352
+
353
+ /** Named aliases, the same ones, so a reader moving between the two files is not
354
+ * comparing spellings. */
355
+ it("names the shared types rather than writing them out", () => {
356
+ const out = generateClientAugmentation(ast)
357
+ expect(out).toContain("export type RichText = SerializedEditorState")
358
+ expect(out).toContain("export type StorageReference = {")
359
+ expect(out).toContain("cover_image: StorageReference | null")
360
+ expect(out).not.toContain("{ bucket: string; path: string }")
361
+ })
362
+
363
+ /** Emitted only when the schema uses them. */
364
+ it("declares no alias the schema does not use", () => {
365
+ const plain = generateClientAugmentation({
366
+ models: [
367
+ {
368
+ name: "Note",
369
+ fields: { body: { kind: "text", required: true } },
370
+ annotations: { db: { tableName: "note", indexes: [] } },
371
+ },
372
+ ],
373
+ })
374
+ expect(plain).not.toContain("RichText")
375
+ expect(plain).not.toContain("StorageReference")
376
+ })
377
+
378
+ /** The columns sit inside the block that holds them. */
379
+ it("indents each column into its own block", () => {
380
+ const out = generateClientAugmentation(ast)
381
+ expect(out).toContain(" title: string")
382
+ expect(out).not.toMatch(/^ {2}title: string$/m)
383
+ })
384
+
385
+ /** In the order the schema declares them, because the AST keeps that order. */
386
+ it("keeps the schema's column order", () => {
387
+ const out = generateClientAugmentation(ast)
388
+ const row = out.split("Row: {")[1]?.split("}")[0] ?? ""
389
+ const order = ["id", "title", "slug", "description", "cover_image"]
390
+ const positions = order.map((column) => row.indexOf(`${column}:`))
391
+ expect(positions).toEqual([...positions].sort((a, b) => a - b))
392
+ })
393
+ })
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The client a project imports, generated into its own source tree.
3
+ *
4
+ * An app writes one import and nothing else:
5
+ *
6
+ * import { createClient } from "./supatype/client"
7
+ *
8
+ * That single file carries three things that previously arrived by three different routes, two of
9
+ * which could silently fail:
10
+ *
11
+ * the module augmentation was a `.d.ts` found only through tsconfig `include`, so a stale or
12
+ * missing entry left every table name accepted and every column
13
+ * `unknown`, with the build passing
14
+ * the field kinds were read off disk and, failing that, reconstructed from PostgREST's
15
+ * OpenAPI document, which always succeeds and so could never report a
16
+ * project that had not generated
17
+ * `createClient` came from the package, which knows nothing about the project
18
+ *
19
+ * Arriving by import means the bundler carries them, tsconfig cannot lose them, nothing is
20
+ * fetched, and a project that has not generated has no file to import, so the build fails.
21
+ */
22
+ import { describe, expect, it } from "vitest"
23
+ import { generateProjectClient } from "../src/client-generator.js"
24
+
25
+ const ast = {
26
+ models: [
27
+ {
28
+ name: "JobPost",
29
+ annotations: { db: { tableName: "job_post" } },
30
+ fields: {
31
+ id: { kind: "uuid", required: true },
32
+ title: { kind: "text", required: true },
33
+ views: { kind: "bigInt", required: true },
34
+ equityPct: { kind: "decimal", required: false },
35
+ },
36
+ },
37
+ ],
38
+ }
39
+
40
+ const plainAst = {
41
+ models: [
42
+ {
43
+ name: "LandingPage",
44
+ annotations: { db: { tableName: "landing_page" } },
45
+ fields: { id: { kind: "uuid", required: true } },
46
+ },
47
+ ],
48
+ }
49
+
50
+ describe("generateProjectClient", () => {
51
+ it("re-exports the client package, so one import is enough", () => {
52
+ expect(generateProjectClient(ast)).toContain('export * from "@supatype/client"')
53
+ })
54
+
55
+ it("carries the module augmentation, rather than relying on tsconfig to find it", () => {
56
+ const out = generateProjectClient(ast)
57
+ expect(out).toContain('declare module "@supatype/client"')
58
+ expect(out).toContain("interface SupatypeModels")
59
+ expect(out).toContain("job_post")
60
+ })
61
+
62
+ it("registers the columns a JSON parser would destroy", () => {
63
+ const out = generateProjectClient(ast)
64
+ expect(out).toContain("registerFieldKinds(")
65
+ expect(out).toContain('"views": "bigint"')
66
+ expect(out).toContain('"equityPct": "numeric"')
67
+ })
68
+
69
+ it("leaves ordinary columns out of the registration", () => {
70
+ // Listing a column that survives a parse would invite the client to convert one that was
71
+ // already correct.
72
+ const out = generateProjectClient(ast)
73
+ expect(out).not.toContain('"title"')
74
+ expect(out).not.toContain('"id": "')
75
+ })
76
+
77
+ it("still registers for a schema with nothing exact", () => {
78
+ // Registered-and-empty is how the client tells "this project generated and has no exact
79
+ // columns" from "this project never generated". Both must not look the same.
80
+ const out = generateProjectClient(plainAst)
81
+ expect(out).toContain("registerFieldKinds(")
82
+ expect(out).toContain('"tables": {}')
83
+ })
84
+
85
+ it("imports the registration function from the package", () => {
86
+ expect(generateProjectClient(ast)).toContain(
87
+ 'import { registerFieldKinds } from "@supatype/client"',
88
+ )
89
+ })
90
+
91
+ it("says it is generated, on the first line", () => {
92
+ expect(generateProjectClient(ast).split("\n")[0]).toContain("Generated by supatype")
93
+ })
94
+
95
+ it("is stable across runs, so it does not churn the diff", () => {
96
+ expect(generateProjectClient(ast)).toBe(generateProjectClient(ast))
97
+ })
98
+
99
+ it("survives an ast it does not recognise", () => {
100
+ expect(() => generateProjectClient({})).not.toThrow()
101
+ expect(() => generateProjectClient(null)).not.toThrow()
102
+ })
103
+ })
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `DATABASE_URL` has to name the port Postgres is published on.
3
+ *
4
+ * `SUPATYPE_DB_PORT` moves the host side of the compose mapping so two projects can coexist on one
5
+ * machine. `DATABASE_URL` is what every host-side script uses. Nothing reconciled them, so a
6
+ * project moved to 5433 started cleanly, served every request, and failed on its own seed script
7
+ * with `ECONNREFUSED 127.0.0.1:5432`, naming a port the person never chose.
8
+ *
9
+ * The cases that matter most here are the ones where it must NOT act: a remote database is not
10
+ * ours to repoint, and a malformed URL is not ours to half-fix.
11
+ */
12
+
13
+ import { describe, expect, it, beforeEach, afterEach } from "vitest"
14
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
15
+ import { join } from "node:path"
16
+ import { tmpdir } from "node:os"
17
+ import { DEFAULT_DB_PORT, publishedDbPort, syncDatabaseUrlPort } from "../src/db-port.js"
18
+
19
+ let dir: string
20
+
21
+ beforeEach(() => {
22
+ dir = mkdtempSync(join(tmpdir(), "supatype-dbport-"))
23
+ })
24
+
25
+ afterEach(() => {
26
+ rmSync(dir, { recursive: true, force: true })
27
+ })
28
+
29
+ function writeEnv(lines: string): void {
30
+ writeFileSync(join(dir, ".env"), lines, "utf8")
31
+ }
32
+
33
+ function envText(): string {
34
+ return readFileSync(join(dir, ".env"), "utf8")
35
+ }
36
+
37
+ describe("publishedDbPort", () => {
38
+ it("defaults to the port compose binds when unset", () => {
39
+ expect(publishedDbPort({})).toBe(DEFAULT_DB_PORT)
40
+ })
41
+
42
+ it("reads the configured port", () => {
43
+ expect(publishedDbPort({ SUPATYPE_DB_PORT: "5433" })).toBe(5433)
44
+ })
45
+
46
+ it("prefers the dev-local port, because its presence marks the deployment shape", () => {
47
+ // Found by running the validation e2e: that project publishes on SUPATYPE_DEV_DB_PORT=54330
48
+ // for its host-side engine, and an earlier version of this file rewrote a correct URL to 5432.
49
+ expect(publishedDbPort({ SUPATYPE_DEV_DB_PORT: "54330" })).toBe(54330)
50
+ expect(publishedDbPort({ SUPATYPE_DEV_DB_PORT: "54330", SUPATYPE_DB_PORT: "5433" })).toBe(54330)
51
+ })
52
+
53
+ it("uses the self-host port when there is no dev-local one", () => {
54
+ expect(publishedDbPort({ SUPATYPE_DB_PORT: "5433" })).toBe(5433)
55
+ })
56
+
57
+ it("falls through a nonsense dev-local port to the self-host one", () => {
58
+ expect(publishedDbPort({ SUPATYPE_DEV_DB_PORT: "abc", SUPATYPE_DB_PORT: "5433" })).toBe(5433)
59
+ })
60
+
61
+ it("falls back rather than inventing a port from nonsense", () => {
62
+ // Compose reports a bad value far better than we can, and a made-up port would send the seed
63
+ // somewhere nothing is listening with no clue why.
64
+ for (const bad of ["", " ", "abc", "-1", "0", "70000", "5433.5"]) {
65
+ expect(publishedDbPort({ SUPATYPE_DB_PORT: bad }), `value ${JSON.stringify(bad)}`).toBe(
66
+ DEFAULT_DB_PORT,
67
+ )
68
+ }
69
+ })
70
+ })
71
+
72
+ describe("syncDatabaseUrlPort", () => {
73
+ it("rewrites the port when the two disagree", () => {
74
+ writeEnv("DATABASE_URL=postgresql://u:p@localhost:5432/acme\nSUPATYPE_DB_PORT=5433\n")
75
+ expect(syncDatabaseUrlPort(dir)).toEqual({ from: "5432", to: "5433" })
76
+ expect(envText()).toContain("localhost:5433/acme")
77
+ })
78
+
79
+ it("keeps the credentials, database and query string intact", () => {
80
+ // The whole point is one field. A rewrite that dropped the password would take the stack down
81
+ // in a way that looks nothing like a port problem.
82
+ writeEnv(
83
+ "DATABASE_URL=postgresql://supatype_admin:s3cr3t@127.0.0.1:5432/acme?sslmode=disable\nSUPATYPE_DB_PORT=5440\n",
84
+ )
85
+ syncDatabaseUrlPort(dir)
86
+ const url = new URL(readFileSync(join(dir, ".env"), "utf8").match(/^DATABASE_URL=(.+)$/m)![1]!)
87
+ expect(url.port).toBe("5440")
88
+ expect(url.username).toBe("supatype_admin")
89
+ expect(url.password).toBe("s3cr3t")
90
+ expect(url.pathname).toBe("/acme")
91
+ expect(url.searchParams.get("sslmode")).toBe("disable")
92
+ })
93
+
94
+ it("does nothing when they already agree", () => {
95
+ writeEnv("DATABASE_URL=postgresql://u:p@localhost:5433/acme\nSUPATYPE_DB_PORT=5433\n")
96
+ expect(syncDatabaseUrlPort(dir)).toBeNull()
97
+ })
98
+
99
+ it("treats an absent port as the default rather than rewriting it every run", () => {
100
+ writeEnv("DATABASE_URL=postgresql://u:p@localhost/acme\n")
101
+ expect(syncDatabaseUrlPort(dir)).toBeNull()
102
+ })
103
+
104
+ it("leaves a remote database alone", () => {
105
+ // Somebody else's Postgres. Its port has nothing to do with our compose mapping, and
106
+ // rewriting it would repoint the project at a database that does not exist.
107
+ const remote = "postgresql://u:p@db.example.com:5432/acme"
108
+ writeEnv(`DATABASE_URL=${remote}\nSUPATYPE_DB_PORT=5433\n`)
109
+ expect(syncDatabaseUrlPort(dir)).toBeNull()
110
+ expect(envText()).toContain(remote)
111
+ })
112
+
113
+ it("leaves a malformed URL alone rather than half-fixing it", () => {
114
+ const broken = "postgres//u:p@localhost:5432/acme"
115
+ writeEnv(`DATABASE_URL=${broken}\nSUPATYPE_DB_PORT=5433\n`)
116
+ expect(syncDatabaseUrlPort(dir)).toBeNull()
117
+ expect(envText()).toContain(broken)
118
+ })
119
+
120
+ it("does nothing when there is no DATABASE_URL at all", () => {
121
+ writeEnv("SUPATYPE_DB_PORT=5433\n")
122
+ expect(syncDatabaseUrlPort(dir)).toBeNull()
123
+ })
124
+
125
+ it("does nothing when there is no .env", () => {
126
+ expect(syncDatabaseUrlPort(dir)).toBeNull()
127
+ })
128
+ })
@@ -4,8 +4,12 @@ import {
4
4
  boundsRequiringHelpers,
5
5
  compareVersions,
6
6
  ENGINE_MIN_FOR_BOUNDS,
7
+ ENGINE_MIN_FOR_RICHTEXT_TRIGGER,
8
+ ENGINE_MIN_FOR_SEED,
7
9
  ENGINE_MIN_FOR_VERSIONS,
10
+ fieldsNeedingRichTextTrigger,
8
11
  modelsRequiringVersions,
12
+ seedUnsupportedByPinnedEngine,
9
13
  } from "../src/engine-floor.js"
10
14
  import type { ExtractedSchemaAstV2, FieldAstV2, ModelAstV2 } from "../src/schema-ast-v2.js"
11
15
 
@@ -36,6 +40,12 @@ function schema(models: ModelAstV2[]): ExtractedSchemaAstV2 {
36
40
  const WITH_BOUND = schema([model("Post", { title: field({ validation: { maxLength: 80 } }) })])
37
41
  const WITHOUT_BOUND = schema([model("Post", { title: field() })])
38
42
 
43
+ const RICH_TEXT = field({ kind: "richText", annotations: { db: { pgType: "JSONB" }, platform: {} } })
44
+ const WITH_RICH_TEXT = schema([model("Post", { title: field(), body: RICH_TEXT })])
45
+
46
+ /** The engine released before either floor, so it is what a project pinned today would carry. */
47
+ const BEFORE_BOTH = "0.3.3"
48
+
39
49
  describe("compareVersions", () => {
40
50
  it("orders by each numeric part, not lexically", () => {
41
51
  expect(compareVersions("0.10.0", "0.9.0")).toBeGreaterThan(0)
@@ -143,3 +153,86 @@ describe("the versions floor", () => {
143
153
  expect(() => { assertEngineSupportsSchema(WITH_BOUND, "0.1.9") }).toThrow(/bounds/)
144
154
  })
145
155
  })
156
+
157
+ describe("the rich text trigger floor", () => {
158
+ it("names the columns that need it", () => {
159
+ expect(fieldsNeedingRichTextTrigger(WITH_RICH_TEXT)).toEqual(["Post.body"])
160
+ expect(fieldsNeedingRichTextTrigger(WITHOUT_BOUND)).toEqual([])
161
+ })
162
+
163
+ it("refuses a rich text column on an engine that cannot normalise it", () => {
164
+ expect(() => { assertEngineSupportsSchema(WITH_RICH_TEXT, BEFORE_BOTH) }).toThrow(
165
+ new RegExp(`schema-engine ${ENGINE_MIN_FOR_RICHTEXT_TRIGGER.replace(/\./g, "\.")} or newer`),
166
+ )
167
+ })
168
+
169
+ it("points at the column, because the remedy is in the schema", () => {
170
+ try {
171
+ assertEngineSupportsSchema(WITH_RICH_TEXT, BEFORE_BOTH)
172
+ expect.unreachable("a rich text column on an older engine has to be refused")
173
+ } catch (err) {
174
+ const message = (err as Error).message
175
+ expect(message).toContain("Post.body")
176
+ expect(message).toContain("rich text columns")
177
+ // The whole point of the refusal: what goes wrong is a read, not the push.
178
+ expect(message).toContain("what the types say is impossible")
179
+ expect(message).toContain(`versions: { engine: "${ENGINE_MIN_FOR_RICHTEXT_TRIGGER}" }`)
180
+ }
181
+ })
182
+
183
+ it("allows the release itself and anything newer", () => {
184
+ expect(() => {
185
+ assertEngineSupportsSchema(WITH_RICH_TEXT, ENGINE_MIN_FOR_RICHTEXT_TRIGGER)
186
+ }).not.toThrow()
187
+ expect(() => { assertEngineSupportsSchema(WITH_RICH_TEXT, "1.0.0") }).not.toThrow()
188
+ })
189
+
190
+ it("says nothing about a schema with no rich text column", () => {
191
+ expect(() => { assertEngineSupportsSchema(WITHOUT_BOUND, BEFORE_BOTH) }).not.toThrow()
192
+ })
193
+
194
+ it("leaves an unpinned or local project alone", () => {
195
+ expect(() => { assertEngineSupportsSchema(WITH_RICH_TEXT, undefined) }).not.toThrow()
196
+ expect(() => { assertEngineSupportsSchema(WITH_RICH_TEXT, "local") }).not.toThrow()
197
+ })
198
+ })
199
+
200
+ describe("seedUnsupportedByPinnedEngine", () => {
201
+ it("refuses a pin with no seed subcommand, and says what to change", () => {
202
+ const message = seedUnsupportedByPinnedEngine(BEFORE_BOTH)
203
+ expect(message).toBeDefined()
204
+ expect(message).toContain(`schema-engine ${ENGINE_MIN_FOR_SEED} or newer`)
205
+ expect(message).toContain(BEFORE_BOTH)
206
+ expect(message).toContain(`versions: { engine: "${ENGINE_MIN_FOR_SEED}" }`)
207
+ })
208
+
209
+ it("allows the release itself and anything newer", () => {
210
+ expect(seedUnsupportedByPinnedEngine(ENGINE_MIN_FOR_SEED)).toBeUndefined()
211
+ expect(seedUnsupportedByPinnedEngine("1.0.0")).toBeUndefined()
212
+ })
213
+
214
+ it("leaves an unpinned or local project alone", () => {
215
+ // Unpinned resolves to latest, which is at or above the floor by definition.
216
+ expect(seedUnsupportedByPinnedEngine(undefined)).toBeUndefined()
217
+ expect(seedUnsupportedByPinnedEngine("local")).toBeUndefined()
218
+ })
219
+
220
+ it("is not conditional on the schema, because every seed needs the subcommand", () => {
221
+ // No argument but the pin: there is nothing a project could declare to opt out of this one,
222
+ // which is what makes it different from every other floor in this file.
223
+ expect(seedUnsupportedByPinnedEngine.length).toBe(1)
224
+ })
225
+ })
226
+
227
+ describe("the floors themselves", () => {
228
+ it("name the engine release that introduced them", () => {
229
+ // Hard-coded rather than read back from the constant. Every other assertion in this file
230
+ // builds its expectation from the value under test, so lowering a floor would move the tests
231
+ // with it and nothing would go red. A floor is a reviewed claim about a published engine, and
232
+ // this is the one place that says which one.
233
+ expect(ENGINE_MIN_FOR_BOUNDS).toBe("0.2.0")
234
+ expect(ENGINE_MIN_FOR_VERSIONS).toBe("0.3.0")
235
+ expect(ENGINE_MIN_FOR_RICHTEXT_TRIGGER).toBe("0.4.0")
236
+ expect(ENGINE_MIN_FOR_SEED).toBe("0.4.0")
237
+ })
238
+ })
@@ -42,13 +42,20 @@ describe("external database: compose", () => {
42
42
  const compose = renderSelfHostCompose(managed())
43
43
  expect(compose).toMatch(/^ {2}db:$/m)
44
44
  expect(compose).toContain("pg_isready")
45
+ // The positive half of "leaves no reference to the db host anywhere" below. Without this, that
46
+ // test's negative assertion could pass against a pattern that matches nothing at all.
47
+ expect(compose).toMatch(/^\s+db:\s*\n\s+condition:/m)
45
48
  })
46
49
 
47
50
  it("leaves no reference to the db host anywhere", () => {
48
51
  const compose = renderSelfHostCompose(external())
49
52
  expect(compose).not.toContain("@db:5432")
50
53
  expect(compose).not.toContain("db-data")
51
- expect(compose).not.toContain("condition: service_healthy")
54
+ // Nothing may wait on a `db` service that is not there. Asserted as a dependency on `db`
55
+ // rather than as the absence of any health condition, which is what this used to say: storage
56
+ // waits for the object store to be healthy in every deployment, because that race is about
57
+ // seaweedfs and has nothing to do with where Postgres lives.
58
+ expect(compose).not.toMatch(/^\s+db:\s*\n\s+condition:/m)
52
59
  })
53
60
 
54
61
  it("points every database consumer at the one URL", () => {