@supatype/cli 0.3.2 → 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 (83) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +163 -161
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +77 -0
  5. package/dist/augmentation-generator.d.ts.map +1 -1
  6. package/dist/augmentation-generator.js +70 -15
  7. package/dist/augmentation-generator.js.map +1 -1
  8. package/dist/cli-version-embedded.js +1 -1
  9. package/dist/commands/generate.d.ts.map +1 -1
  10. package/dist/commands/generate.js +3 -3
  11. package/dist/commands/generate.js.map +1 -1
  12. package/dist/commands/init.d.ts.map +1 -1
  13. package/dist/commands/init.js +29 -19
  14. package/dist/commands/init.js.map +1 -1
  15. package/dist/commands/seed.d.ts +27 -1
  16. package/dist/commands/seed.d.ts.map +1 -1
  17. package/dist/commands/seed.js +206 -33
  18. package/dist/commands/seed.js.map +1 -1
  19. package/dist/engine-client.d.ts +1 -0
  20. package/dist/engine-client.d.ts.map +1 -1
  21. package/dist/engine-client.js +47 -2
  22. package/dist/engine-client.js.map +1 -1
  23. package/dist/engine-floor.d.ts +27 -4
  24. package/dist/engine-floor.d.ts.map +1 -1
  25. package/dist/engine-floor.js +90 -9
  26. package/dist/engine-floor.js.map +1 -1
  27. package/dist/gitignore.d.ts.map +1 -1
  28. package/dist/gitignore.js +7 -0
  29. package/dist/gitignore.js.map +1 -1
  30. package/dist/hooks-generator.d.ts.map +1 -1
  31. package/dist/hooks-generator.js +18 -3
  32. package/dist/hooks-generator.js.map +1 -1
  33. package/dist/seed-connection.d.ts +60 -0
  34. package/dist/seed-connection.d.ts.map +1 -0
  35. package/dist/seed-connection.js +90 -0
  36. package/dist/seed-connection.js.map +1 -0
  37. package/dist/seed-ir.d.ts +66 -0
  38. package/dist/seed-ir.d.ts.map +1 -0
  39. package/dist/seed-ir.js +265 -0
  40. package/dist/seed-ir.js.map +1 -0
  41. package/dist/seed-runner.d.ts +44 -0
  42. package/dist/seed-runner.d.ts.map +1 -0
  43. package/dist/seed-runner.js +83 -0
  44. package/dist/seed-runner.js.map +1 -0
  45. package/dist/seed.d.ts +345 -0
  46. package/dist/seed.d.ts.map +1 -1
  47. package/dist/seed.js +138 -0
  48. package/dist/seed.js.map +1 -1
  49. package/dist/self-host-compose.d.ts.map +1 -1
  50. package/dist/self-host-compose.js +14 -8
  51. package/dist/self-host-compose.js.map +1 -1
  52. package/dist/type-extractor.d.ts.map +1 -1
  53. package/dist/type-extractor.js +73 -0
  54. package/dist/type-extractor.js.map +1 -1
  55. package/dist/type-generation.d.ts +29 -0
  56. package/dist/type-generation.d.ts.map +1 -1
  57. package/dist/type-generation.js +52 -0
  58. package/dist/type-generation.js.map +1 -1
  59. package/package.json +2 -2
  60. package/src/augmentation-generator.ts +73 -14
  61. package/src/cli-version-embedded.ts +1 -1
  62. package/src/commands/generate.ts +7 -3
  63. package/src/commands/init.ts +31 -21
  64. package/src/commands/seed.ts +294 -44
  65. package/src/engine-client.ts +52 -3
  66. package/src/engine-floor.ts +100 -9
  67. package/src/gitignore.ts +7 -0
  68. package/src/hooks-generator.ts +21 -6
  69. package/src/seed-connection.ts +146 -0
  70. package/src/seed-ir.ts +311 -0
  71. package/src/seed-runner.ts +109 -0
  72. package/src/seed.ts +408 -0
  73. package/src/self-host-compose.ts +14 -8
  74. package/src/type-extractor.ts +101 -0
  75. package/src/type-generation.ts +64 -0
  76. package/tests/access-composition.test.ts +123 -0
  77. package/tests/augmentation-generator.test.ts +149 -9
  78. package/tests/engine-floor.test.ts +93 -0
  79. package/tests/fixtures/seed_golden_ir.json +186 -0
  80. package/tests/runtime-contract.test.ts +31 -6
  81. package/tests/seed-connection.test.ts +158 -0
  82. package/tests/seed-ir.test.ts +436 -0
  83. package/tsconfig.tsbuildinfo +1 -1
@@ -580,6 +580,129 @@ export type Post = Model<{
580
580
  })
581
581
  })
582
582
 
583
+
584
+ // `After` / `Before`: a point in time plus or minus a multi-unit duration. The
585
+ // composition `Ago` and `FromNow` could not express, because they carry one amount
586
+ // and one unit.
587
+ it("extracts a composed offset from a truncation base", () => {
588
+ const post = extract(
589
+ `
590
+ import type { Model, UUID, All, Gte, Lte, After, Before, Now, StartOf } from "@supatype/types"
591
+
592
+ export type Post = Model<{
593
+ id: UUID
594
+ }, {
595
+ access: {
596
+ read: All<[
597
+ Gte<"starts_at", After<StartOf<"day">, { days: 30, hours: 9 }>>,
598
+ Lte<"created_at", Before<Now, { weeks: 2 }>>
599
+ ]>
600
+ }
601
+ }>
602
+ `,
603
+ "offsets",
604
+ )
605
+
606
+ expect(access(post)["read"]).toEqual({
607
+ type: "all",
608
+ rules: [
609
+ {
610
+ type: "compare",
611
+ op: "gte",
612
+ left: { kind: "column", name: "starts_at" },
613
+ right: {
614
+ kind: "after",
615
+ base: { kind: "startOf", unit: "day" },
616
+ interval: { days: 30, hours: 9 },
617
+ },
618
+ },
619
+ {
620
+ type: "compare",
621
+ op: "lte",
622
+ left: { kind: "column", name: "created_at" },
623
+ right: {
624
+ kind: "before",
625
+ base: { kind: "now" },
626
+ interval: { weeks: 2 },
627
+ },
628
+ },
629
+ ],
630
+ })
631
+ })
632
+
633
+ // The base is what makes an offset meaningful. A column depends on the row, so it is
634
+ // not a point in time an offset can be measured from, and the engine refuses one too.
635
+ it("rejects a base that is not a point in time", () => {
636
+ expect(() =>
637
+ extract(
638
+ `
639
+ import type { Model, UUID, Gte, After, Ago } from "@supatype/types"
640
+ export type Post = Model<{ id: UUID }, {
641
+ access: { read: Gte<"a", After<Ago<1, "days">, { days: 1 }>> }
642
+ }>
643
+ `,
644
+ "offset-base",
645
+ ),
646
+ ).toThrow(/must be `Now` or `StartOf/)
647
+ })
648
+
649
+ it("rejects an unknown unit inside the interval", () => {
650
+ expect(() =>
651
+ extract(
652
+ `
653
+ import type { Model, UUID, Gte, After, Now } from "@supatype/types"
654
+ export type Post = Model<{ id: UUID }, {
655
+ access: { read: Gte<"a", After<Now, { dayz: 30 }>> }
656
+ }>
657
+ `,
658
+ "offset-unit",
659
+ ),
660
+ ).toThrow(/is not a unit of time/)
661
+ })
662
+
663
+ // Same rule as Ago/FromNow: direction lives in the operand name, not in a sign, so a
664
+ // negative amount is refused with the opposite operand named.
665
+ it("rejects a negative or fractional amount and names the opposite operand", () => {
666
+ const bad = (n: string) => `
667
+ import type { Model, UUID, Gte, After, Now } from "@supatype/types"
668
+ export type Post = Model<{ id: UUID }, {
669
+ access: { read: Gte<"a", After<Now, { days: ${n} }>> }
670
+ }>
671
+ `
672
+ expect(() => extract(bad("-5"), "offset-neg")).toThrow(/does not take a negative amount/)
673
+ expect(() => extract(bad("-5"), "offset-neg2")).toThrow(/`Before<>`/)
674
+ expect(() => extract(bad("0.5"), "offset-frac")).toThrow(/whole number/)
675
+ })
676
+
677
+ // An all-zero interval means the base unchanged, which is never what an author wrote.
678
+ it("rejects an interval that sums to nothing", () => {
679
+ expect(() =>
680
+ extract(
681
+ `
682
+ import type { Model, UUID, Gte, After, Now } from "@supatype/types"
683
+ export type Post = Model<{ id: UUID }, {
684
+ access: { read: Gte<"a", After<Now, { days: 0 }>> }
685
+ }>
686
+ `,
687
+ "offset-zero",
688
+ ),
689
+ ).toThrow(/empty interval/)
690
+ })
691
+
692
+ it("rejects a unit given twice", () => {
693
+ expect(() =>
694
+ extract(
695
+ `
696
+ import type { Model, UUID, Gte, After, Now } from "@supatype/types"
697
+ export type Post = Model<{ id: UUID }, {
698
+ access: { read: Gte<"a", After<Now, { days: 1, days: 2 }>> }
699
+ }>
700
+ `,
701
+ "offset-dup",
702
+ ),
703
+ ).toThrow(/twice/)
704
+ })
705
+
583
706
  // A permissive `"30 days"` string would be raw SQL by another name, so the amount
584
707
  // and unit are validated separately and the interval is reassembled from them.
585
708
  it("rejects a fractional or negative amount", () => {
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"
2
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`
@@ -234,16 +268,17 @@ describe("column shapes that disagreed with the database", () => {
234
268
  })
235
269
 
236
270
  it("types an image as what storage.upload actually produces", () => {
237
- // Was `Record<string, unknown>`, which is why the examples cast on the good path.
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.
238
273
  const row = generateRowType({
239
274
  headshot: { kind: "image", required: false, bucket: "speaker-headshots" },
240
275
  })
241
- expect(row).toContain("headshot: { bucket: string; path: string } | null")
276
+ expect(row).toContain("headshot: StorageReference | null")
242
277
  })
243
278
 
244
279
  it("types a file the same way", () => {
245
280
  const row = generateRowType({ attachment: { kind: "file", required: true } })
246
- expect(row).toContain("attachment: { bucket: string; path: string }")
281
+ expect(row).toContain("attachment: StorageReference")
247
282
  })
248
283
 
249
284
  it("does not store a url, because a stored one goes stale", () => {
@@ -251,3 +286,108 @@ describe("column shapes that disagreed with the database", () => {
251
286
  expect(row).not.toContain("url")
252
287
  })
253
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
+ })
@@ -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
+ })
@@ -0,0 +1,186 @@
1
+ {
2
+ "irVersion": 1,
3
+ "schemaFingerprint": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
4
+ "operations": [
5
+ {
6
+ "op": "upsert",
7
+ "model": "Profile",
8
+ "id": "alex",
9
+ "where": {
10
+ "id": "a0000000-0000-4000-8000-000000000001"
11
+ },
12
+ "data": {
13
+ "display_name": "Alex Rivera",
14
+ "full_name": "Alex Rivera"
15
+ }
16
+ },
17
+ {
18
+ "op": "upsert",
19
+ "model": "Organiser",
20
+ "id": "northern",
21
+ "where": {
22
+ "slug": "northern-lights-events"
23
+ },
24
+ "data": {
25
+ "name": "Northern Lights Events",
26
+ "contact_email": "hello@northernlights.test",
27
+ "owner": {
28
+ "connect": {
29
+ "id": {
30
+ "$ref": "alex.id"
31
+ }
32
+ }
33
+ },
34
+ "events": {
35
+ "create": [
36
+ {
37
+ "title": "Southwest Dev Conference",
38
+ "status": "published",
39
+ "starts_at": {
40
+ "kind": "after",
41
+ "base": {
42
+ "kind": "startOf",
43
+ "unit": "day"
44
+ },
45
+ "interval": {
46
+ "days": 30,
47
+ "hours": 9
48
+ }
49
+ },
50
+ "ends_at": {
51
+ "kind": "after",
52
+ "base": {
53
+ "kind": "startOf",
54
+ "unit": "day"
55
+ },
56
+ "interval": {
57
+ "days": 30,
58
+ "hours": 18
59
+ }
60
+ },
61
+ "created_at": {
62
+ "kind": "now"
63
+ },
64
+ "ticket_types": {
65
+ "createMany": [
66
+ {
67
+ "name": "Early Bird",
68
+ "price": {
69
+ "$numeric": "75.0000"
70
+ },
71
+ "quantity": 100
72
+ },
73
+ {
74
+ "name": "Standard",
75
+ "price": {
76
+ "$numeric": "125.0000"
77
+ },
78
+ "quantity": 250
79
+ }
80
+ ]
81
+ }
82
+ }
83
+ ]
84
+ }
85
+ }
86
+ },
87
+ {
88
+ "op": "createMany",
89
+ "model": "Venue",
90
+ "rows": [
91
+ {
92
+ "name": "The Exchange",
93
+ "city": "Bristol",
94
+ "capacity": 450,
95
+ "country": {
96
+ "$default": true
97
+ },
98
+ "organiser": {
99
+ "connect": {
100
+ "slug": "northern-lights-events"
101
+ }
102
+ }
103
+ },
104
+ {
105
+ "name": "Archway Studios",
106
+ "city": "London",
107
+ "capacity": 180,
108
+ "country": {
109
+ "$default": true
110
+ },
111
+ "organiser": {
112
+ "connect": {
113
+ "slug": "northern-lights-events"
114
+ }
115
+ }
116
+ }
117
+ ]
118
+ },
119
+ {
120
+ "op": "group",
121
+ "if": {
122
+ "kind": "notExists",
123
+ "model": "SiteSettings"
124
+ },
125
+ "operations": [
126
+ {
127
+ "op": "create",
128
+ "model": "SiteSettings",
129
+ "data": {
130
+ "site_name": "Launch Test Tickets",
131
+ "default_currency": "GBP"
132
+ }
133
+ }
134
+ ]
135
+ },
136
+ {
137
+ "op": "group",
138
+ "if": {
139
+ "kind": "exists",
140
+ "model": "Event",
141
+ "where": {
142
+ "type": "compare",
143
+ "op": "eq",
144
+ "left": {
145
+ "kind": "column",
146
+ "name": "status"
147
+ },
148
+ "right": {
149
+ "kind": "literal",
150
+ "value": "published"
151
+ }
152
+ }
153
+ },
154
+ "operations": [
155
+ {
156
+ "op": "create",
157
+ "model": "PromoCode",
158
+ "data": {
159
+ "code": "EARLY20",
160
+ "discount_kind": "percentage",
161
+ "discount_value": 20,
162
+ "valid_until": {
163
+ "kind": "after",
164
+ "base": {
165
+ "kind": "now"
166
+ },
167
+ "interval": {
168
+ "days": 25
169
+ }
170
+ },
171
+ "event": {
172
+ "connect": {
173
+ "slug": "southwest-dev-conference"
174
+ }
175
+ }
176
+ }
177
+ }
178
+ ]
179
+ },
180
+ {
181
+ "op": "sql",
182
+ "text": "ANALYZE event",
183
+ "params": []
184
+ }
185
+ ]
186
+ }
@@ -276,22 +276,47 @@ describe("runtime contract", () => {
276
276
  expect(compose).not.toContain("- ./kong.yml:/etc/kong/kong.yml:ro")
277
277
  })
278
278
 
279
- it("devLocal compose omits host-published db and server ports", () => {
279
+ it("devLocal compose omits the production ports", () => {
280
280
  const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
281
281
  expect(compose).not.toContain('"5432:5432"')
282
282
  expect(compose).not.toContain('"9999:9999"')
283
283
  expect(compose).toContain("${SUPATYPE_KONG_PORT:-18473}:8000")
284
- expect(compose).not.toContain("SUPATYPE_DEV_DB_PORT")
285
284
  })
286
285
 
287
- it("devLocal compose publishes db to host when overrides.engine is set", () => {
288
- const compose = renderSelfHostCompose(
286
+ /**
287
+ * The database is reachable from this machine, always.
288
+ *
289
+ * It used to be published in dev only when something was known to need it, which meant a
290
+ * fresh project had a database nothing on the host could reach: `supatype seed` resolves a
291
+ * connection string and the engine connects over TCP, so the first seed got ECONNREFUSED
292
+ * and the remedy was an environment variable nobody had reason to know about.
293
+ */
294
+ it("devLocal compose publishes the database on loopback", () => {
295
+ const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
296
+ expect(compose).toContain("127.0.0.1:${SUPATYPE_DEV_DB_PORT:-54329}:5432")
297
+ })
298
+
299
+ /** Loopback, so it is reachable from here and from nowhere else. */
300
+ it("devLocal never publishes the database beyond this machine", () => {
301
+ const compose = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
302
+ const dbPorts = compose
303
+ .split("\n")
304
+ .filter((line) => line.includes(":5432"))
305
+ .filter((line) => line.trim().startsWith("-"))
306
+ expect(dbPorts.length).toBeGreaterThan(0)
307
+ for (const line of dbPorts) expect(line).toContain("127.0.0.1:")
308
+ })
309
+
310
+ it("an engine override no longer decides whether the database is published", () => {
311
+ const withOverride = renderSelfHostCompose(
289
312
  { ...baseConfig, overrides: { engine: "/tmp/supatype-engine" } },
290
313
  process.cwd(),
291
314
  { devLocal: true },
292
315
  )
293
- expect(compose).toContain("127.0.0.1:${SUPATYPE_DEV_DB_PORT:-54329}:5432")
294
- expect(compose).not.toContain('"5432:5432"')
316
+ const without = renderSelfHostCompose(baseConfig, process.cwd(), { devLocal: true })
317
+ expect(withOverride).toContain("127.0.0.1:${SUPATYPE_DEV_DB_PORT:-54329}:5432")
318
+ expect(without).toContain("127.0.0.1:${SUPATYPE_DEV_DB_PORT:-54329}:5432")
319
+ expect(withOverride).not.toContain('"5432:5432"')
295
320
  })
296
321
 
297
322
  it("devLocal proxy upstream rewrites localhost to host.docker.internal", () => {