@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
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Which database a seed run targets, and what it says when it cannot tell.
3
+ *
4
+ * The error is the reason this is a file of its own. The old seed template carried a
5
+ * hardcoded fallback DSN, so a project with no `.env` did not fail: it tried
6
+ * `localhost:5432` with a guessed password and reported a connection error naming none of
7
+ * the things it had looked at. Someone reading that cannot tell "I have not set
8
+ * DATABASE_URL" from "the stack is not running", which is the difference between one
9
+ * command and half an hour.
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, it } from "vitest"
13
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
14
+ import { tmpdir } from "node:os"
15
+ import { join } from "node:path"
16
+ import { DsnNotFound, redact, resolveSeedDsn } from "../src/seed-connection.js"
17
+ import type { SupatypeProjectConfig } from "../src/project-config.js"
18
+
19
+ const SECRET = "postgres://user:s3cret@db.example.test:5432/app"
20
+
21
+ function config(overrides: Partial<SupatypeProjectConfig> = {}): SupatypeProjectConfig {
22
+ return {
23
+ project: { name: "launch-test" },
24
+ database: { provider: "docker" },
25
+ ...overrides,
26
+ } as SupatypeProjectConfig
27
+ }
28
+
29
+ let cwd: string
30
+ let previousDatabaseUrl: string | undefined
31
+
32
+ beforeEach(() => {
33
+ cwd = mkdtempSync(join(tmpdir(), "supatype-dsn-"))
34
+ mkdirSync(cwd, { recursive: true })
35
+ previousDatabaseUrl = process.env["DATABASE_URL"]
36
+ delete process.env["DATABASE_URL"]
37
+ })
38
+
39
+ afterEach(() => {
40
+ if (previousDatabaseUrl === undefined) delete process.env["DATABASE_URL"]
41
+ else process.env["DATABASE_URL"] = previousDatabaseUrl
42
+ rmSync(cwd, { recursive: true, force: true })
43
+ })
44
+
45
+ describe("where the connection string comes from", () => {
46
+ it("takes --connection first, because it was typed just now", () => {
47
+ process.env["DATABASE_URL"] = "postgres://env/one"
48
+ const resolved = resolveSeedDsn(cwd, config(), { connection: SECRET })
49
+ expect(resolved.dsn).toBe(SECRET)
50
+ expect(resolved.source).toBe("--connection")
51
+ })
52
+
53
+ /**
54
+ * A stated external database is the whole stack's database. Seeding somewhere else while
55
+ * the services read from here would look exactly like data loss, so it outranks the rest.
56
+ */
57
+ it("prefers a declared external database over DATABASE_URL", () => {
58
+ process.env["DATABASE_URL"] = "postgres://env/one"
59
+ const resolved = resolveSeedDsn(
60
+ cwd,
61
+ // `provider` is omitted when `external` is set: there is no backend to choose.
62
+ config({ database: { external: { url: SECRET } } } as Partial<SupatypeProjectConfig>),
63
+ )
64
+ expect(resolved.dsn).toBe(SECRET)
65
+ })
66
+
67
+ it("takes `connection` from the config before the environment", () => {
68
+ process.env["DATABASE_URL"] = "postgres://env/one"
69
+ const resolved = resolveSeedDsn(cwd, config({ connection: SECRET } as Partial<SupatypeProjectConfig>))
70
+ expect(resolved.dsn).toBe(SECRET)
71
+ })
72
+
73
+ it("takes DATABASE_URL from the environment", () => {
74
+ process.env["DATABASE_URL"] = SECRET
75
+ const resolved = resolveSeedDsn(cwd, config())
76
+ expect(resolved.dsn).toBe(SECRET)
77
+ expect(resolved.source).toBe("DATABASE_URL in the environment")
78
+ })
79
+
80
+ it("falls back to the project's .env", () => {
81
+ writeFileSync(join(cwd, ".env"), `DATABASE_URL=${SECRET}\n`, "utf8")
82
+ const resolved = resolveSeedDsn(cwd, config())
83
+ expect(resolved.dsn).toBe(SECRET)
84
+ expect(resolved.source).toContain(".env")
85
+ })
86
+
87
+ /** The usual rule, and the one Compose follows for the same names. */
88
+ it("lets a real environment variable beat the file", () => {
89
+ writeFileSync(join(cwd, ".env"), "DATABASE_URL=postgres://file/one\n", "utf8")
90
+ process.env["DATABASE_URL"] = "postgres://env/two"
91
+ expect(resolveSeedDsn(cwd, config()).dsn).toBe("postgres://env/two")
92
+ })
93
+
94
+ it("derives one for a project whose database it manages", () => {
95
+ const resolved = resolveSeedDsn(cwd, config(), { allowDerived: true })
96
+ expect(resolved.dsn).toContain("launch-test")
97
+ expect(resolved.source).toBe("the project's own local database")
98
+ })
99
+ })
100
+
101
+ describe("when nothing names a database", () => {
102
+ it("refuses rather than guessing", () => {
103
+ expect(() => resolveSeedDsn(cwd, config())).toThrow(DsnNotFound)
104
+ })
105
+
106
+ it("names every source it tried, and what was at each", () => {
107
+ let message = ""
108
+ try {
109
+ resolveSeedDsn(cwd, config())
110
+ } catch (e) {
111
+ message = e instanceof Error ? e.message : String(e)
112
+ }
113
+
114
+ for (const source of [
115
+ "--connection",
116
+ "database.external.url in supatype.config.ts",
117
+ "connection in supatype.config.ts",
118
+ "DATABASE_URL in the environment",
119
+ ".env",
120
+ ]) {
121
+ expect(message).toContain(source)
122
+ }
123
+ expect(message).toContain("no .env file")
124
+ })
125
+
126
+ it("distinguishes a missing .env from one without the key", () => {
127
+ writeFileSync(join(cwd, ".env"), "SOMETHING_ELSE=1\n", "utf8")
128
+ let message = ""
129
+ try {
130
+ resolveSeedDsn(cwd, config())
131
+ } catch (e) {
132
+ message = e instanceof Error ? e.message : String(e)
133
+ }
134
+ expect(message).toContain("the file has no DATABASE_URL")
135
+ })
136
+ })
137
+
138
+ describe("what reaches the terminal", () => {
139
+ /**
140
+ * Every one of these ends up in a terminal and often in a CI log. Printing a DSN to
141
+ * explain where it came from should not be how a password ends up somewhere it is kept.
142
+ */
143
+ it("removes the password", () => {
144
+ expect(redact(SECRET)).toBe("postgres://user:***@db.example.test:5432/app")
145
+ expect(redact(SECRET)).not.toContain("s3cret")
146
+ })
147
+
148
+ it("leaves a connection string with no password alone", () => {
149
+ expect(redact("postgres://db.example.test:5432/app")).toBe("postgres://db.example.test:5432/app")
150
+ })
151
+
152
+ it("does not print the password while explaining where it looked", () => {
153
+ process.env["DATABASE_URL"] = SECRET
154
+ const resolved = resolveSeedDsn(cwd, config())
155
+ const printed = resolved.tried.map((source) => source.detail).join("\n")
156
+ expect(printed).not.toContain("s3cret")
157
+ })
158
+ })
@@ -0,0 +1,436 @@
1
+ /**
2
+ * The TypeScript builder against the golden IR document.
3
+ *
4
+ * `supatype-schema-engine/tests/fixtures/seed_golden_ir.json` is written by hand from the IR
5
+ * specification, not captured from either end. That is what makes this test worth having: it
6
+ * checks the builder against the contract rather than against itself, and the first
7
+ * non-TypeScript builder will be checked against the same file rather than against whatever
8
+ * this one happened to emit.
9
+ *
10
+ * Compared canonically, with every object's keys sorted. The two ends legitimately write
11
+ * keys in different orders, and a byte comparison would fail on that while telling nobody
12
+ * anything about whether the documents mean the same thing.
13
+ */
14
+
15
+ import { describe, expect, it } from "vitest"
16
+ import { readFileSync } from "node:fs"
17
+ import { createHash } from "node:crypto"
18
+ import { dirname, join, resolve } from "node:path"
19
+ import { fileURLToPath } from "node:url"
20
+ import { SeedCollector, type Manifest } from "../src/seed-ir.js"
21
+ import { eq, expr, type SeedDb, type SeedOperation } from "../src/seed.js"
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url))
24
+
25
+ const GOLDEN = resolve(here, "fixtures", "seed_golden_ir.json")
26
+
27
+ /**
28
+ * The engine's copy of the same file, and the reason a second copy is safe.
29
+ *
30
+ * This used to read the engine's fixture directly across a sibling checkout, which is the right
31
+ * instinct and does not survive CI: this repository is cloned on its own, so the path did not
32
+ * exist and the test had never once run there.
33
+ *
34
+ * So the file is vendored, and both repositories assert this hash. Editing either copy turns its
35
+ * own suite red until the hash is updated, and updating the hash turns the other repository red
36
+ * until its copy follows. Neither can drift quietly, which is the only property the shared path
37
+ * was buying.
38
+ *
39
+ * Engine side: `tests/seed_ir_tests.rs`, same constant.
40
+ */
41
+ const GOLDEN_SHA256 = "6ec5e51820868e0e303abe3c5aa57e034ff27c53374998d760d425863e538ea6"
42
+
43
+ const FINGERPRINT = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
44
+
45
+ /**
46
+ * The manifest the generated builder would emit for the fixture's schema.
47
+ *
48
+ * Hand-written here so this test covers the collector alone. That the generator produces a
49
+ * manifest matching a real schema is the engine's to prove, and that the two meet is the
50
+ * acceptance app's.
51
+ */
52
+ const MANIFEST: Manifest = {
53
+ profile: {
54
+ model: "Profile",
55
+ table: "profile",
56
+ numeric: [],
57
+ temporal: [],
58
+ relations: {},
59
+ },
60
+ organiser: {
61
+ model: "Organiser",
62
+ table: "organiser",
63
+ numeric: [],
64
+ temporal: [],
65
+ relations: {
66
+ owner: { kind: "belongsTo", target: "Profile" },
67
+ events: { kind: "hasMany", target: "Event" },
68
+ },
69
+ },
70
+ event: {
71
+ model: "Event",
72
+ table: "event",
73
+ numeric: [],
74
+ temporal: ["starts_at", "ends_at", "created_at"],
75
+ relations: {
76
+ organiser: { kind: "belongsTo", target: "Organiser" },
77
+ ticket_types: { kind: "hasMany", target: "TicketType" },
78
+ },
79
+ },
80
+ ticket_type: {
81
+ model: "TicketType",
82
+ table: "ticket_type",
83
+ numeric: ["price"],
84
+ temporal: [],
85
+ relations: { event: { kind: "belongsTo", target: "Event" } },
86
+ },
87
+ venue: {
88
+ model: "Venue",
89
+ table: "venue",
90
+ numeric: [],
91
+ temporal: [],
92
+ relations: { organiser: { kind: "belongsTo", target: "Organiser" } },
93
+ },
94
+ _global_site_settings: {
95
+ model: "SiteSettings",
96
+ table: "_global_site_settings",
97
+ numeric: [],
98
+ temporal: [],
99
+ relations: {},
100
+ },
101
+ promo_code: {
102
+ model: "PromoCode",
103
+ table: "promo_code",
104
+ numeric: [],
105
+ temporal: ["valid_until"],
106
+ relations: { event: { kind: "belongsTo", target: "Event" } },
107
+ },
108
+ }
109
+
110
+ /** Sort every object's keys, so key order stops being part of the comparison. */
111
+ function canonical(value: unknown): unknown {
112
+ if (Array.isArray(value)) return value.map(canonical)
113
+ if (value !== null && typeof value === "object") {
114
+ const entries = Object.entries(value as Record<string, unknown>)
115
+ entries.sort(([a], [b]) => a.localeCompare(b))
116
+ return Object.fromEntries(entries.map(([key, inner]) => [key, canonical(inner)]))
117
+ }
118
+ return value
119
+ }
120
+
121
+ function collector(): SeedCollector {
122
+ return new SeedCollector(FINGERPRINT, MANIFEST)
123
+ }
124
+
125
+ /**
126
+ * The seed a person would write to produce the golden document.
127
+ *
128
+ * Typed loosely on purpose: the generated builder is what supplies the real types, and this
129
+ * test is about what the collector emits rather than about what compiles.
130
+ */
131
+ function writeTheGoldenSeed(db: SeedDb): void {
132
+ const models = db as unknown as Record<string, {
133
+ create: (data: Record<string, unknown>, options?: { id?: string }) => Record<string, unknown>
134
+ createMany: (rows: Record<string, unknown>[]) => void
135
+ upsert: (args: {
136
+ where: Record<string, unknown>
137
+ data: Record<string, unknown>
138
+ id?: string
139
+ }) => Record<string, unknown>
140
+ }>
141
+
142
+ const alex = models["profile"]!.upsert({
143
+ where: { id: "a0000000-0000-4000-8000-000000000001" },
144
+ data: { display_name: "Alex Rivera", full_name: "Alex Rivera" },
145
+ id: "alex",
146
+ })
147
+
148
+ models["organiser"]!.upsert({
149
+ where: { slug: "northern-lights-events" },
150
+ data: {
151
+ name: "Northern Lights Events",
152
+ contact_email: "hello@northernlights.test",
153
+ owner: { connect: { id: alex["id"] } },
154
+ events: {
155
+ create: [
156
+ {
157
+ title: "Southwest Dev Conference",
158
+ status: "published",
159
+ starts_at: expr.startOf("day", { days: 30, hours: 9 }),
160
+ ends_at: expr.startOf("day", { days: 30, hours: 18 }),
161
+ created_at: expr.now(),
162
+ ticket_types: {
163
+ createMany: [
164
+ { name: "Early Bird", price: "75.0000", quantity: 100 },
165
+ { name: "Standard", price: "125.0000", quantity: 250 },
166
+ ],
167
+ },
168
+ },
169
+ ],
170
+ },
171
+ },
172
+ id: "northern",
173
+ })
174
+
175
+ const venue = { connect: { slug: "northern-lights-events" } }
176
+ models["venue"]!.createMany([
177
+ {
178
+ name: "The Exchange",
179
+ city: "Bristol",
180
+ capacity: 450,
181
+ country: expr.default(),
182
+ organiser: venue,
183
+ },
184
+ {
185
+ name: "Archway Studios",
186
+ city: "London",
187
+ capacity: 180,
188
+ country: expr.default(),
189
+ organiser: venue,
190
+ },
191
+ ])
192
+
193
+ db.$if({ notExists: { model: "SiteSettings" } }, () => {
194
+ models["_global_site_settings"]!.create({
195
+ site_name: "Launch Test Tickets",
196
+ default_currency: "GBP",
197
+ })
198
+ })
199
+
200
+ db.$if({ exists: { model: "Event", where: eq("status", "published") } }, () => {
201
+ models["promo_code"]!.create({
202
+ code: "EARLY20",
203
+ discount_kind: "percentage",
204
+ discount_value: 20,
205
+ valid_until: expr.now({ days: 25 }),
206
+ event: { connect: { slug: "southwest-dev-conference" } },
207
+ })
208
+ })
209
+
210
+ db.$sql("ANALYZE event")
211
+ }
212
+
213
+ describe("the builder against the golden IR", () => {
214
+ it("reproduces the document the specification describes", () => {
215
+ const collect = collector()
216
+ writeTheGoldenSeed(collect.db())
217
+ const produced = collect.finish().ir
218
+
219
+ const golden: unknown = JSON.parse(readFileSync(GOLDEN, "utf8"))
220
+ expect(canonical(produced)).toEqual(canonical(golden))
221
+ })
222
+
223
+ it("covers every operation kind, or it is not much of a contract", () => {
224
+ const collect = collector()
225
+ writeTheGoldenSeed(collect.db())
226
+
227
+ // Recursively, because two of the five only appear inside a group: a coverage check
228
+ // that looked at the top level alone would pass while missing them.
229
+ const kinds = new Set<string>()
230
+ const walk = (operations: readonly SeedOperation[]): void => {
231
+ for (const operation of operations) {
232
+ kinds.add(operation.op)
233
+ if (operation.op === "group") walk(operation.operations)
234
+ }
235
+ }
236
+ walk(collect.finish().ir.operations)
237
+
238
+ expect(kinds).toEqual(new Set(["create", "createMany", "upsert", "group", "sql"]))
239
+ })
240
+ })
241
+
242
+ describe("what the collector encodes", () => {
243
+ it("labels an operation only when something points at it", () => {
244
+ const collect = collector()
245
+ const models = collect.db() as unknown as Record<
246
+ string,
247
+ { create: (data: Record<string, unknown>) => Record<string, unknown> }
248
+ >
249
+ models["profile"]!.create({ display_name: "Unreferenced" })
250
+ const referenced = models["profile"]!.create({ display_name: "Referenced" })
251
+ models["organiser"]!.create({ name: "N", owner: { connect: { id: referenced["id"] } } })
252
+
253
+ const operations = collect.finish().ir.operations
254
+ expect(operations[0]).not.toHaveProperty("id")
255
+ expect(operations[1]).toHaveProperty("id")
256
+ })
257
+
258
+ /**
259
+ * A decimal column must not round-trip through a double. `0.1 + 0.2` is the reason, and a
260
+ * currency amount is the least acceptable place for it.
261
+ */
262
+ it("sends an exact-decimal column as text and leaves other numbers alone", () => {
263
+ const collect = collector()
264
+ const models = collect.db() as unknown as Record<
265
+ string,
266
+ { create: (data: Record<string, unknown>) => Record<string, unknown> }
267
+ >
268
+ models["ticket_type"]!.create({ name: "Early", price: 75.5, quantity: 100 })
269
+
270
+ const operation = collect.finish().ir.operations[0]
271
+ expect(operation).toMatchObject({
272
+ op: "create",
273
+ data: { price: { $numeric: "75.5" }, quantity: 100 },
274
+ })
275
+ })
276
+
277
+ it("renders a Date as an instant", () => {
278
+ const collect = collector()
279
+ const models = collect.db() as unknown as Record<
280
+ string,
281
+ { create: (data: Record<string, unknown>) => Record<string, unknown> }
282
+ >
283
+ models["event"]!.create({ title: "Conf", starts_at: new Date("2026-01-01T09:00:00.000Z") })
284
+
285
+ expect(collect.finish().ir.operations[0]).toMatchObject({
286
+ data: { starts_at: "2026-01-01T09:00:00.000Z" },
287
+ })
288
+ })
289
+
290
+ /** `expr.now()` is already the shape the engine reads, so it must pass through untouched. */
291
+ it("leaves an expression alone rather than encoding it again", () => {
292
+ const collect = collector()
293
+ const models = collect.db() as unknown as Record<
294
+ string,
295
+ { create: (data: Record<string, unknown>) => Record<string, unknown> }
296
+ >
297
+ models["event"]!.create({ title: "Conf", created_at: expr.now() })
298
+
299
+ expect(collect.finish().ir.operations[0]).toMatchObject({
300
+ data: { created_at: { kind: "now" } },
301
+ })
302
+ })
303
+
304
+ it("collects a group's operations into the group, not beside it", () => {
305
+ const collect = collector()
306
+ const db = collect.db()
307
+ const models = db as unknown as Record<
308
+ string,
309
+ { create: (data: Record<string, unknown>) => Record<string, unknown> }
310
+ >
311
+ db.$if({ notExists: { model: "SiteSettings" } }, () => {
312
+ models["profile"]!.create({ display_name: "Inside" })
313
+ })
314
+ models["profile"]!.create({ display_name: "Outside" })
315
+
316
+ const operations = collect.finish().ir.operations
317
+ expect(operations).toHaveLength(2)
318
+ expect(operations[0]).toMatchObject({
319
+ op: "group",
320
+ if: { kind: "notExists", model: "SiteSettings" },
321
+ })
322
+ expect(operations[1]).toMatchObject({ op: "create" })
323
+ })
324
+
325
+ /**
326
+ * A seed that throws inside a `$if` must not leave every later call collecting into a
327
+ * block that is never emitted, which would lose them silently.
328
+ */
329
+ it("leaves the block it was collecting into when a group throws", () => {
330
+ const collect = collector()
331
+ const db = collect.db()
332
+ const models = db as unknown as Record<
333
+ string,
334
+ { create: (data: Record<string, unknown>) => Record<string, unknown> }
335
+ >
336
+ expect(() => {
337
+ db.$if({ notExists: { model: "SiteSettings" } }, () => {
338
+ throw new Error("the seed failed")
339
+ })
340
+ }).toThrow("the seed failed")
341
+
342
+ models["profile"]!.create({ display_name: "After" })
343
+ const operations = collect.finish().ir.operations
344
+ expect(operations).toHaveLength(1)
345
+ expect(operations[0]).toMatchObject({ op: "create" })
346
+ })
347
+
348
+ it("normalises a hasOne nested create into the list the IR carries", () => {
349
+ const collect = collector()
350
+ const models = collect.db() as unknown as Record<
351
+ string,
352
+ { create: (data: Record<string, unknown>) => Record<string, unknown> }
353
+ >
354
+ models["organiser"]!.create({
355
+ name: "N",
356
+ events: { create: { title: "Only one" } },
357
+ })
358
+
359
+ expect(collect.finish().ir.operations[0]).toMatchObject({
360
+ data: { events: { create: [{ title: "Only one" }] } },
361
+ })
362
+ })
363
+
364
+ /** A reference is a thenable-looking object, and awaiting one must not hang. */
365
+ it("does not answer to `then`, so a builder call can be awaited harmlessly", async () => {
366
+ const collect = collector()
367
+ const models = collect.db() as unknown as Record<
368
+ string,
369
+ { create: (data: Record<string, unknown>) => Record<string, unknown> }
370
+ >
371
+ const row = models["profile"]!.create({ display_name: "Alex" })
372
+ await expect(Promise.resolve(row)).resolves.toBeDefined()
373
+ })
374
+ })
375
+
376
+ describe("the predicate helpers", () => {
377
+ it("serialise to the rule tree the schema already uses", () => {
378
+ expect(eq("status", "published")).toEqual({
379
+ type: "compare",
380
+ op: "eq",
381
+ left: { kind: "column", name: "status" },
382
+ right: { kind: "literal", value: "published" },
383
+ })
384
+ })
385
+ })
386
+
387
+ describe("the expression helpers", () => {
388
+ it("compose a base and an interval rather than inventing a second vocabulary", () => {
389
+ expect(expr.now()).toEqual({ kind: "now" })
390
+ expect(expr.now({ days: 30 })).toEqual({
391
+ kind: "after",
392
+ base: { kind: "now" },
393
+ interval: { days: 30 },
394
+ })
395
+ expect(expr.startOf("day", { days: 30, hours: 9 })).toEqual({
396
+ kind: "after",
397
+ base: { kind: "startOf", unit: "day" },
398
+ interval: { days: 30, hours: 9 },
399
+ })
400
+ expect(expr.ago({ days: 7 })).toEqual({
401
+ kind: "before",
402
+ base: { kind: "now" },
403
+ interval: { days: 7 },
404
+ })
405
+ })
406
+
407
+ /**
408
+ * The engine's interval components are whole and unsigned, so one that is not fails to read
409
+ * as an operand, falls through to being a plain value, and reaches the database as JSON
410
+ * text in a date column. That surfaced as `invalid input syntax for type timestamp with
411
+ * time zone`, naming neither the field nor the real mistake. Refused at the call instead.
412
+ */
413
+ it("refuses a negative or fractional component, where the mistake was made", () => {
414
+ expect(() => expr.now({ days: -20 })).toThrow(/whole number and not negative/)
415
+ expect(() => expr.startOf("day", { hours: 1.5 })).toThrow(/whole number/)
416
+ expect(() => expr.now({ days: -20 })).toThrow(/ago/)
417
+ })
418
+
419
+ /** Two equal intervals must serialise identically, or the golden fixture is untestable. */
420
+ it("drops the units nobody asked for", () => {
421
+ expect(expr.now({ days: 30, hours: 0 })).toEqual(expr.now({ days: 30 }))
422
+ expect(expr.now({})).toEqual({ kind: "now" })
423
+ })
424
+ })
425
+
426
+ describe("where the fixture lives", () => {
427
+ it("is the engine's copy, not a second one that can drift", () => {
428
+ expect(GOLDEN).toContain(join("tests", "fixtures"))
429
+ // Line endings normalised first. git hands this file out as CRLF on Windows and LF on
430
+ // Linux, so hashing the bytes as checked out pins the platform rather than the content.
431
+ const normalised = readFileSync(GOLDEN, "utf8").split("\r\n").join("\n")
432
+ const digest = createHash("sha256").update(normalised, "utf8").digest("hex")
433
+ expect(digest, "the vendored copy has drifted from the engine's").toBe(GOLDEN_SHA256)
434
+ expect(() => readFileSync(GOLDEN, "utf8")).not.toThrow()
435
+ })
436
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Strict mode, which decides whether a degraded path is survivable.
3
+ *
4
+ * The behaviour under test is the *difference* between the two modes, so each case asserts both
5
+ * halves. A test that only checked the throw would pass against an implementation that threw
6
+ * always, which would break every interactive fallback the CLI has on purpose.
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
10
+ import { degraded, strict } from "../src/strict.js"
11
+
12
+ const KEY = "SUPATYPE_STRICT"
13
+ let original: string | undefined
14
+
15
+ beforeEach(() => {
16
+ original = process.env[KEY]
17
+ delete process.env[KEY]
18
+ })
19
+
20
+ afterEach(() => {
21
+ if (original === undefined) delete process.env[KEY]
22
+ else process.env[KEY] = original
23
+ vi.restoreAllMocks()
24
+ })
25
+
26
+ describe("strict()", () => {
27
+ it("is off when the variable is unset", () => {
28
+ expect(strict()).toBe(false)
29
+ })
30
+
31
+ it("is on only for exactly \"1\"", () => {
32
+ process.env[KEY] = "1"
33
+ expect(strict()).toBe(true)
34
+ })
35
+
36
+ it("is off for other truthy-looking values", () => {
37
+ // Deliberately narrow. A flag that accepts "true", "yes" and "on" is a flag whose meaning has
38
+ // to be looked up, and this one turns warnings into build failures.
39
+ for (const value of ["true", "yes", "on", "0", ""]) {
40
+ process.env[KEY] = value
41
+ expect(strict(), `value ${JSON.stringify(value)}`).toBe(false)
42
+ }
43
+ })
44
+ })
45
+
46
+ describe("degraded()", () => {
47
+ it("warns and returns when strict is off, so an interactive run continues", () => {
48
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
49
+ expect(() => degraded("Host engine unavailable", "CDN refused")).not.toThrow()
50
+ expect(warn).toHaveBeenCalledTimes(1)
51
+ expect(warn.mock.calls[0]?.[0]).toContain("Host engine unavailable")
52
+ })
53
+
54
+ it("throws when strict is on, so CI cannot pass over it", () => {
55
+ process.env[KEY] = "1"
56
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
57
+ expect(() => degraded("Host engine unavailable", "CDN refused")).toThrow(
58
+ /Host engine unavailable/,
59
+ )
60
+ // Not warned as well: a run that fails should not also print the line that says it recovered.
61
+ expect(warn).not.toHaveBeenCalled()
62
+ })
63
+
64
+ it("names the capability and the cause, because the assertion that fails names neither", () => {
65
+ process.env[KEY] = "1"
66
+ // The failure this exists to prevent read "expected validator refusal, got 200". The cause was
67
+ // an engine download refused twenty lines earlier, and nothing in the failure said so.
68
+ expect(() => degraded("Host engine unavailable", "no minisign public key configured")).toThrow(
69
+ /no minisign public key configured/,
70
+ )
71
+ })
72
+
73
+ it("says which flag made it fatal", () => {
74
+ process.env[KEY] = "1"
75
+ // Otherwise the reader's first move is to work out why a warning became an error.
76
+ expect(() => degraded("Bucket unreachable", "ECONNREFUSED")).toThrow(/SUPATYPE_STRICT=1/)
77
+ })
78
+ })