@supatype/cli 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +169 -161
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/api-config-cache.d.ts +20 -0
- package/dist/api-config-cache.d.ts.map +1 -1
- package/dist/api-config-cache.js +27 -0
- package/dist/api-config-cache.js.map +1 -1
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +63 -18
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/client-generator.d.ts +3 -0
- package/dist/client-generator.d.ts.map +1 -0
- package/dist/client-generator.js +108 -0
- package/dist/client-generator.js.map +1 -0
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +2 -2
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +7 -10
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +14 -13
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +19 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/db-port.d.ts +36 -0
- package/dist/db-port.d.ts.map +1 -0
- package/dist/db-port.js +90 -0
- package/dist/db-port.js.map +1 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +108 -13
- package/dist/dev-compose.js.map +1 -1
- package/dist/functions-context-refresh.d.ts +4 -0
- package/dist/functions-context-refresh.d.ts.map +1 -0
- package/dist/functions-context-refresh.js +26 -0
- package/dist/functions-context-refresh.js.map +1 -0
- package/dist/functions-deno-types.d.ts +9 -0
- package/dist/functions-deno-types.d.ts.map +1 -1
- package/dist/functions-deno-types.js +21 -1
- package/dist/functions-deno-types.js.map +1 -1
- package/dist/gitignore.d.ts +3 -1
- package/dist/gitignore.d.ts.map +1 -1
- package/dist/gitignore.js +28 -6
- package/dist/gitignore.js.map +1 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +17 -8
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/self-host-compose.d.ts +10 -2
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +67 -6
- package/dist/self-host-compose.js.map +1 -1
- package/dist/strict.d.ts +29 -0
- package/dist/strict.d.ts.map +1 -0
- package/dist/strict.js +37 -0
- package/dist/strict.js.map +1 -0
- package/dist/studio-dev-server.d.ts +12 -2
- package/dist/studio-dev-server.d.ts.map +1 -1
- package/dist/studio-dev-server.js +12 -2
- package/dist/studio-dev-server.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +8 -2
- package/dist/type-extractor.js.map +1 -1
- package/dist/type-generation.d.ts +14 -0
- package/dist/type-generation.d.ts.map +1 -1
- package/dist/type-generation.js +41 -2
- package/dist/type-generation.js.map +1 -1
- package/package.json +2 -2
- package/src/api-config-cache.ts +30 -0
- package/src/augmentation-generator.ts +75 -18
- package/src/cli-version-embedded.ts +1 -1
- package/src/client-generator.ts +122 -0
- package/src/commands/functions.ts +5 -2
- package/src/commands/init.ts +10 -10
- package/src/commands/push.ts +14 -15
- package/src/commands/update.ts +20 -1
- package/src/db-port.ts +99 -0
- package/src/dev-compose.ts +116 -16
- package/src/functions-context-refresh.ts +25 -0
- package/src/functions-deno-types.ts +24 -1
- package/src/gitignore.ts +31 -10
- package/src/schema-ast-v2.ts +17 -8
- package/src/self-host-compose.ts +68 -6
- package/src/strict.ts +40 -0
- package/src/studio-dev-server.ts +12 -2
- package/src/type-extractor.ts +8 -2
- package/src/type-generation.ts +47 -2
- package/tests/ast-derived-outputs.test.ts +92 -0
- package/tests/augmentation-generator.test.ts +127 -1
- package/tests/client-generator.test.ts +103 -0
- package/tests/db-port.test.ts +128 -0
- package/tests/external-database-compose.test.ts +8 -1
- package/tests/functions-context-refresh.test.ts +86 -0
- package/tests/gitignore-secrets.test.ts +90 -0
- package/tests/runtime-contract.test.ts +115 -2
- package/tests/strict.test.ts +78 -0
- package/tests/type-extractor.test.ts +34 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/type-generation.ts
CHANGED
|
@@ -13,9 +13,13 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { mkdirSync, writeFileSync } from "node:fs"
|
|
16
|
-
import { dirname, resolve } from "node:path"
|
|
16
|
+
import { dirname, join, resolve, sep } from "node:path"
|
|
17
17
|
import { generateClientAugmentation } from "./augmentation-generator.js"
|
|
18
18
|
import { ensureEngine, engineRequest } from "./engine-client.js"
|
|
19
|
+
import { generateProjectClient } from "./client-generator.js"
|
|
20
|
+
|
|
21
|
+
/** The client a project imports, beside the generated types. */
|
|
22
|
+
const PROJECT_CLIENT_FILENAME = "client.ts"
|
|
19
23
|
|
|
20
24
|
export interface GenerateTypesRequest {
|
|
21
25
|
cwd: string
|
|
@@ -48,7 +52,27 @@ export async function writeGeneratedTypes(req: GenerateTypesRequest): Promise<st
|
|
|
48
52
|
written.push(`Types written to ${req.typesPath}`)
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
written.push(...writeAstDerivedOutputs(req))
|
|
56
|
+
|
|
57
|
+
return written
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The generated files that come from the AST alone, with no engine round trip.
|
|
62
|
+
*
|
|
63
|
+
* Shared because three commands need them and only one was writing them. `push` and `generate`
|
|
64
|
+
* call {@link writeGeneratedTypes}; `dev` has its own type generation, for good reasons it should
|
|
65
|
+
* keep, and so never wrote these at all. The result was a project developed entirely through
|
|
66
|
+
* `supatype dev` that never received `index.d.ts`, so the module augmentation that makes
|
|
67
|
+
* `createClient` typed without a generic never happened, and never received the generated client,
|
|
68
|
+
* so every exact-value column fell back to asking the API.
|
|
69
|
+
*
|
|
70
|
+
* Kept separate from the types branch above rather than folding `dev` into it: that branch slices
|
|
71
|
+
* the engine's output from a marker and treats a failure as fatal, and `dev` must do neither.
|
|
72
|
+
*/
|
|
73
|
+
export function writeAstDerivedOutputs(req: GenerateTypesRequest): string[] {
|
|
74
|
+
const written: string[] = []
|
|
75
|
+
|
|
52
76
|
if (req.clientPath !== undefined && req.clientPath !== "") {
|
|
53
77
|
const outPath = resolve(req.cwd, req.clientPath)
|
|
54
78
|
mkdirSync(dirname(outPath), { recursive: true })
|
|
@@ -56,5 +80,26 @@ export async function writeGeneratedTypes(req: GenerateTypesRequest): Promise<st
|
|
|
56
80
|
written.push(`Client augmentation written to ${req.clientPath}`)
|
|
57
81
|
}
|
|
58
82
|
|
|
83
|
+
// The client the project imports. One file carrying the augmentation, the exact-value columns
|
|
84
|
+
// and a re-export of `createClient`, so an app writes a single import and tsconfig cannot lose
|
|
85
|
+
// any of it. See `client-generator.ts` for what each part replaced.
|
|
86
|
+
// Beside `output.client` when set, otherwise beside `output.types`. A project that configured
|
|
87
|
+
// only types still gets a usable client: this one file carries the augmentation and the exact
|
|
88
|
+
// columns as well as `createClient`, so without it the project has types and no way to use them
|
|
89
|
+
// that knows anything about its schema.
|
|
90
|
+
const clientDir = req.clientPath ?? req.typesPath
|
|
91
|
+
if (clientDir !== undefined && clientDir !== "") {
|
|
92
|
+
const relative = join(dirname(clientDir), PROJECT_CLIENT_FILENAME)
|
|
93
|
+
const outPath = resolve(req.cwd, relative)
|
|
94
|
+
mkdirSync(dirname(outPath), { recursive: true })
|
|
95
|
+
writeFileSync(outPath, generateProjectClient(req.ast), "utf8")
|
|
96
|
+
written.push(`Client written to ${toPosix(relative)}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
59
99
|
return written
|
|
60
100
|
}
|
|
101
|
+
|
|
102
|
+
/** Reported with forward slashes, so the message reads the same on every platform. */
|
|
103
|
+
function toPosix(path: string): string {
|
|
104
|
+
return path.split(sep).join("/")
|
|
105
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated files that need no engine, and which `dev` never wrote.
|
|
3
|
+
*
|
|
4
|
+
* `push` and `generate` go through `writeGeneratedTypes`. `dev` has its own type generation, which
|
|
5
|
+
* it should keep: it slices the engine's output from a marker and treats a failure as a warning
|
|
6
|
+
* rather than killing the session. What it should not have had is its own idea of which files
|
|
7
|
+
* exist, and the result was that a project developed entirely through `supatype dev` never
|
|
8
|
+
* received the module augmentation that types `createClient` without a generic.
|
|
9
|
+
*
|
|
10
|
+
* Both files come from the AST alone, so they are shared and all three commands write them.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it, beforeEach, afterEach } from "vitest"
|
|
13
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
|
|
14
|
+
import { join } from "node:path"
|
|
15
|
+
import { tmpdir } from "node:os"
|
|
16
|
+
import { writeAstDerivedOutputs } from "../src/type-generation.js"
|
|
17
|
+
|
|
18
|
+
const ast = {
|
|
19
|
+
models: [
|
|
20
|
+
{
|
|
21
|
+
name: "JobPost",
|
|
22
|
+
annotations: { db: { tableName: "job_post" } },
|
|
23
|
+
fields: {
|
|
24
|
+
id: { kind: "uuid", required: true },
|
|
25
|
+
views: { kind: "bigInt", required: true },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let cwd: string
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
cwd = mkdtempSync(join(tmpdir(), "supatype-outputs-"))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe("writeAstDerivedOutputs", () => {
|
|
42
|
+
it("writes the augmentation and the project client together", () => {
|
|
43
|
+
const written = writeAstDerivedOutputs({
|
|
44
|
+
cwd,
|
|
45
|
+
ast,
|
|
46
|
+
typesPath: "supatype/generated/database.ts",
|
|
47
|
+
clientPath: "supatype/generated/index.d.ts",
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
expect(existsSync(join(cwd, "supatype/generated/index.d.ts"))).toBe(true)
|
|
51
|
+
expect(existsSync(join(cwd, "supatype/generated/client.ts"))).toBe(true)
|
|
52
|
+
expect(written).toHaveLength(2)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("puts the client beside the generated types", () => {
|
|
56
|
+
// An app imports it by a relative path, so it has to land next to the rest of the generated
|
|
57
|
+
// output rather than at the project root.
|
|
58
|
+
writeAstDerivedOutputs({ cwd, ast, clientPath: "supatype/generated/index.d.ts" })
|
|
59
|
+
const body = readFileSync(join(cwd, "supatype/generated/client.ts"), "utf8")
|
|
60
|
+
|
|
61
|
+
expect(body).toContain('export * from "@supatype/client"')
|
|
62
|
+
expect(body).toContain('declare module "@supatype/client"')
|
|
63
|
+
expect(body).toContain('"views": "bigint"')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("writes nothing when a project configured no output at all", () => {
|
|
67
|
+
// `push` must not start creating files in projects that never asked for any.
|
|
68
|
+
expect(writeAstDerivedOutputs({ cwd, ast })).toEqual([])
|
|
69
|
+
expect(existsSync(join(cwd, "supatype"))).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("needs no engine, so it can run when type generation could not", () => {
|
|
73
|
+
// This is why it is separate: in `dev` the engine may be unavailable, and the augmentation and
|
|
74
|
+
// the client should still reach the project.
|
|
75
|
+
const written = writeAstDerivedOutputs({ cwd, ast, clientPath: "supatype/generated/index.d.ts" })
|
|
76
|
+
expect(written.some((line) => line.includes("Client written"))).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe("a project that configured only types", () => {
|
|
81
|
+
it("still gets a client, beside the types", () => {
|
|
82
|
+
// Otherwise it has generated types and no way to use them that knows its schema: the
|
|
83
|
+
// augmentation and the exact-value columns both travel in this file.
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), "supatype-types-only-"))
|
|
85
|
+
try {
|
|
86
|
+
writeAstDerivedOutputs({ cwd: dir, ast, typesPath: "supatype/generated/database.ts" })
|
|
87
|
+
expect(existsSync(join(dir, "supatype/generated/client.ts"))).toBe(true)
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(dir, { recursive: true, force: true })
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
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
5
|
it("emits deterministic output independent of model order", () => {
|
|
@@ -125,3 +125,129 @@ describe("generateClientAugmentation", () => {
|
|
|
125
125
|
expect(out).toContain("notes: Record<string, unknown> | null")
|
|
126
126
|
})
|
|
127
127
|
})
|
|
128
|
+
|
|
129
|
+
describe("exact-value numeric kinds", () => {
|
|
130
|
+
/**
|
|
131
|
+
* A column whose value does not survive an IEEE-754 double must not be typed as one.
|
|
132
|
+
*
|
|
133
|
+
* `decimal` used to emit `number` here while `@supatype/types` declared `Decimal<P, S>` as
|
|
134
|
+
* `string` and the engine emitted `string`, so the ambient client type contradicted both and
|
|
135
|
+
* told a caller a rounded value was exact.
|
|
136
|
+
*/
|
|
137
|
+
it("types money, decimal and bigInt so the exact value survives", () => {
|
|
138
|
+
const out = generateClientAugmentation({
|
|
139
|
+
models: [
|
|
140
|
+
{
|
|
141
|
+
name: "Invoice",
|
|
142
|
+
annotations: { db: { tableName: "invoice", indexes: [] } },
|
|
143
|
+
fields: {
|
|
144
|
+
total: { kind: "money", required: true },
|
|
145
|
+
rate: { kind: "decimal", required: true },
|
|
146
|
+
ref: { kind: "bigInt", required: true },
|
|
147
|
+
qty: { kind: "integer", required: true },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
expect(out).toContain("total: string")
|
|
154
|
+
expect(out).toContain("rate: string")
|
|
155
|
+
expect(out).toContain("ref: bigint")
|
|
156
|
+
// The kinds that genuinely fit a double are untouched.
|
|
157
|
+
expect(out).toContain("qty: number")
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe("relation fields", () => {
|
|
162
|
+
const talk = {
|
|
163
|
+
models: [
|
|
164
|
+
{
|
|
165
|
+
name: "Talk",
|
|
166
|
+
annotations: { db: { tableName: "talk", indexes: [] } },
|
|
167
|
+
fields: {
|
|
168
|
+
title: { kind: "text", required: true },
|
|
169
|
+
speaker: {
|
|
170
|
+
kind: "relation",
|
|
171
|
+
cardinality: "belongsTo",
|
|
172
|
+
target: "Speaker",
|
|
173
|
+
annotations: { db: { foreignKey: "speaker_id" } },
|
|
174
|
+
},
|
|
175
|
+
venue: {
|
|
176
|
+
kind: "relation",
|
|
177
|
+
cardinality: "belongsTo",
|
|
178
|
+
target: "Room",
|
|
179
|
+
required: true,
|
|
180
|
+
annotations: { db: { foreignKey: "room_id" } },
|
|
181
|
+
},
|
|
182
|
+
authUser: { kind: "relation", cardinality: "belongsTo", target: "User" },
|
|
183
|
+
slots: { kind: "relation", cardinality: "hasMany", target: "Slot" },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A `belongsTo` is a foreign key column, not an embedded object.
|
|
191
|
+
*
|
|
192
|
+
* The generator used to group `relation` with geo/vector/image and emit
|
|
193
|
+
* `Record<string, unknown>` under the *declared* name, so the augmentation claimed a column
|
|
194
|
+
* named `speaker` holding an object while Postgres and the engine both had `speaker_id`
|
|
195
|
+
* holding text. Wrong name and wrong type on the same column.
|
|
196
|
+
*/
|
|
197
|
+
it("emits the foreign key column, not the declared relation name", () => {
|
|
198
|
+
const out = generateClientAugmentation(talk)
|
|
199
|
+
expect(out).toContain("speaker_id: string | null")
|
|
200
|
+
expect(out).not.toContain("speaker: Record<string, unknown>")
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("keeps a required relation non-nullable, matching the engine", () => {
|
|
204
|
+
expect(generateClientAugmentation(talk)).toContain("room_id: string\n")
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
/** No `foreignKey` annotation, so the column name falls back to the same convention. */
|
|
208
|
+
it("derives the column name when the AST carries no foreignKey", () => {
|
|
209
|
+
expect(generateClientAugmentation(talk)).toContain("auth_user_id: string | null")
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
/** The key lives on the other table, so this side has no column to describe. */
|
|
213
|
+
it("omits hasMany, which is not a column on this table", () => {
|
|
214
|
+
const out = generateClientAugmentation(talk)
|
|
215
|
+
expect(out).not.toContain("slots")
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe("column shapes that disagreed with the database", () => {
|
|
220
|
+
it("types a localized column as a locale map", () => {
|
|
221
|
+
// `page.title` is jsonb holding {"en": "...", "fr": "..."} and this said `string`, so
|
|
222
|
+
// `page.title.toUpperCase()` compiled and then failed in the browser.
|
|
223
|
+
const row = generateRowType({
|
|
224
|
+
title: { kind: "text", required: true, localized: true },
|
|
225
|
+
})
|
|
226
|
+
expect(row).toContain("title: { [locale: string]: string }")
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it("leaves a non-localized column of the same kind alone", () => {
|
|
230
|
+
// The control: a fix that wrapped every text column would pass the assertion above.
|
|
231
|
+
const row = generateRowType({ slug: { kind: "text", required: true } })
|
|
232
|
+
expect(row).toContain("slug: string")
|
|
233
|
+
expect(row).not.toContain("locale")
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
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.
|
|
238
|
+
const row = generateRowType({
|
|
239
|
+
headshot: { kind: "image", required: false, bucket: "speaker-headshots" },
|
|
240
|
+
})
|
|
241
|
+
expect(row).toContain("headshot: { bucket: string; path: string } | null")
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("types a file the same way", () => {
|
|
245
|
+
const row = generateRowType({ attachment: { kind: "file", required: true } })
|
|
246
|
+
expect(row).toContain("attachment: { bucket: string; path: string }")
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it("does not store a url, because a stored one goes stale", () => {
|
|
250
|
+
const row = generateRowType({ headshot: { kind: "image", required: true } })
|
|
251
|
+
expect(row).not.toContain("url")
|
|
252
|
+
})
|
|
253
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -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
|
-
|
|
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", () => {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `functions/_shared/context.ts` reaches a project that already exists.
|
|
3
|
+
*
|
|
4
|
+
* The module was written by `init` and `supatype functions new`, and by nothing else. So it arrived
|
|
5
|
+
* with new projects and new functions, and never arrived at a project that already had functions
|
|
6
|
+
* when the two-argument handler contract landed: those keep `(req)` handlers with no
|
|
7
|
+
* `FunctionContext` to import, while the generated file itself asserts
|
|
8
|
+
*
|
|
9
|
+
* Regenerated by the CLI. Edits are overwritten.
|
|
10
|
+
*
|
|
11
|
+
* which was true only if you scaffolded again. `tests/integration` in this repo had four functions
|
|
12
|
+
* and no context module at all, which is how it was found.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it, beforeEach, afterEach } from "vitest"
|
|
15
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
import { tmpdir } from "node:os"
|
|
18
|
+
import { refreshFunctionsContext } from "../src/functions-context-refresh.js"
|
|
19
|
+
import type { SupatypeProjectConfig } from "../src/project-config.js"
|
|
20
|
+
|
|
21
|
+
const config: SupatypeProjectConfig = {
|
|
22
|
+
project: { name: "ctx" },
|
|
23
|
+
database: { provider: "docker" },
|
|
24
|
+
} as SupatypeProjectConfig
|
|
25
|
+
|
|
26
|
+
let dir: string
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
dir = mkdtempSync(join(tmpdir(), "supatype-ctx-"))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** A project with functions but no `_shared`, which is what a pre-migration project looks like. */
|
|
37
|
+
function projectWithFunctions(): string {
|
|
38
|
+
const functions = join(dir, "functions")
|
|
39
|
+
mkdirSync(functions, { recursive: true })
|
|
40
|
+
writeFileSync(
|
|
41
|
+
join(functions, "ping.ts"),
|
|
42
|
+
"export default function ping(_req: Request) { return new Response('ok') }\n",
|
|
43
|
+
"utf8",
|
|
44
|
+
)
|
|
45
|
+
return functions
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("a project that already has functions", () => {
|
|
49
|
+
it("gets the context module written", () => {
|
|
50
|
+
const functions = projectWithFunctions()
|
|
51
|
+
expect(existsSync(join(functions, "_shared", "context.ts"))).toBe(false)
|
|
52
|
+
|
|
53
|
+
expect(refreshFunctionsContext(dir, config)).toBe(true)
|
|
54
|
+
expect(existsSync(join(functions, "_shared", "context.ts"))).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("writes a module that actually declares the contract", () => {
|
|
58
|
+
// Not merely that a file appeared. The point is the type handlers import.
|
|
59
|
+
projectWithFunctions()
|
|
60
|
+
refreshFunctionsContext(dir, config)
|
|
61
|
+
const source = readFileSync(join(dir, "functions", "_shared", "context.ts"), "utf8")
|
|
62
|
+
expect(source).toContain("FunctionContext")
|
|
63
|
+
expect(source).toContain("executionId")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("rewrites a stale copy rather than leaving it", () => {
|
|
67
|
+
// The file says edits are overwritten, so they have to be.
|
|
68
|
+
const functions = projectWithFunctions()
|
|
69
|
+
mkdirSync(join(functions, "_shared"), { recursive: true })
|
|
70
|
+
writeFileSync(join(functions, "_shared", "context.ts"), "// stale\n", "utf8")
|
|
71
|
+
|
|
72
|
+
refreshFunctionsContext(dir, config)
|
|
73
|
+
const source = readFileSync(join(functions, "_shared", "context.ts"), "utf8")
|
|
74
|
+
expect(source).not.toBe("// stale\n")
|
|
75
|
+
expect(source).toContain("FunctionContext")
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe("a project with no functions", () => {
|
|
80
|
+
it("is left alone", () => {
|
|
81
|
+
// Writing `_shared/` into a project with no functions would create the thing it describes out
|
|
82
|
+
// of nothing, and leave a directory nobody asked for in the diff.
|
|
83
|
+
expect(refreshFunctionsContext(dir, config)).toBe(false)
|
|
84
|
+
expect(existsSync(join(dir, "functions"))).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
})
|