@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
package/src/seed-ir.ts ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Collect a seed's builder calls into an IR document.
3
+ *
4
+ * The only genuinely new file in the CLI half of this: everything else is rewiring. What it
5
+ * does is narrow. It records calls in the order they were made, encodes each value into the
6
+ * shape the engine reads, and hands back a document. It resolves nothing, orders nothing,
7
+ * validates nothing and connects to nothing, because the engine does all four and doing any
8
+ * of them twice is how the two ends come to disagree.
9
+ *
10
+ * **Labels are assigned lazily.** A `create` becomes a referenceable operation only when
11
+ * something actually reads a property off what it returned. Assigning one to every operation
12
+ * would put an `id` in the IR for rows nobody points at, which is noise in a document people
13
+ * read and diff.
14
+ */
15
+
16
+ import type {
17
+ DefaultExpr,
18
+ ModelApi,
19
+ ModelManifest,
20
+ NumericValue,
21
+ Operand,
22
+ Predicate,
23
+ RefValue,
24
+ SeedCondition,
25
+ SeedConditionInput,
26
+ SeedData,
27
+ SeedDb,
28
+ SeedIr,
29
+ SeedOperation,
30
+ SeedValue,
31
+ } from "./seed.js"
32
+
33
+ /** A model's own name and the facts the encoder needs about its columns. */
34
+ export type Manifest = Readonly<Record<string, ModelManifest>>
35
+
36
+ /** What a collector hands back once the seed has run. */
37
+ export interface CollectedIr {
38
+ ir: SeedIr
39
+ /** The document as the engine will read it, which is what the ledger hashes. */
40
+ json: string
41
+ }
42
+
43
+ /** One operation, still mutable while the seed is running. */
44
+ interface Pending {
45
+ operation: SeedOperation
46
+ /** Set the first time something reads a property off this operation's result. */
47
+ label?: string
48
+ }
49
+
50
+ export class SeedCollector {
51
+ private readonly manifest: Manifest
52
+ private readonly byModel: Map<string, ModelManifest>
53
+ /** The block currently being collected into: the document, or a `$if` body. */
54
+ private readonly stack: SeedOperation[][] = []
55
+ private readonly root: SeedOperation[] = []
56
+ private labels = 0
57
+
58
+ constructor(
59
+ private readonly fingerprint: string,
60
+ manifest: Manifest,
61
+ ) {
62
+ this.manifest = manifest
63
+ this.byModel = new Map(Object.values(manifest).map((entry) => [entry.model, entry]))
64
+ this.stack.push(this.root)
65
+ }
66
+
67
+ /** The `db` a seed's default export is handed. */
68
+ db(): SeedDb {
69
+ const api: Record<string, unknown> = {}
70
+ for (const [accessor, entry] of Object.entries(this.manifest)) {
71
+ api[accessor] = this.modelApi(entry)
72
+ }
73
+ api["$if"] = (condition: SeedConditionInput, body: () => void): void => {
74
+ this.group(condition, body)
75
+ }
76
+ api["$sql"] = (text: string, params?: readonly SeedValue[]): void => {
77
+ // Always present, even when empty: the engine defaults it either way, and a document
78
+ // whose shape changes with its contents is harder to read and to diff.
79
+ this.push({ op: "sql", text, params: params === undefined ? [] : [...params] })
80
+ }
81
+ return api as SeedDb
82
+ }
83
+
84
+ /** The document, with every label that was actually used written in. */
85
+ finish(): CollectedIr {
86
+ const ir: SeedIr = {
87
+ irVersion: 1,
88
+ schemaFingerprint: this.fingerprint,
89
+ operations: this.root,
90
+ }
91
+ return { ir, json: JSON.stringify(ir, null, 2) }
92
+ }
93
+
94
+ // --- Operations ---
95
+
96
+ private modelApi(entry: ModelManifest): ModelApi<SeedData, SeedData, Record<string, RefValue>> {
97
+ return {
98
+ create: (data, options) => {
99
+ const pending: Pending = {
100
+ operation: { op: "create", model: entry.model, data: this.encodeRow(entry, data) },
101
+ }
102
+ if (options?.id !== undefined) this.name(pending, options.id)
103
+ this.push(pending.operation)
104
+ return this.reference(pending)
105
+ },
106
+
107
+ createMany: (rows) => {
108
+ this.push({
109
+ op: "createMany",
110
+ model: entry.model,
111
+ rows: rows.map((row) => this.encodeRow(entry, row)),
112
+ })
113
+ },
114
+
115
+ upsert: ({ where, data, id }) => {
116
+ const pending: Pending = {
117
+ operation: {
118
+ op: "upsert",
119
+ model: entry.model,
120
+ where: this.encodeRow(entry, where),
121
+ data: this.encodeRow(entry, data),
122
+ },
123
+ }
124
+ if (id !== undefined) this.name(pending, id)
125
+ this.push(pending.operation)
126
+ return this.reference(pending)
127
+ },
128
+ }
129
+ }
130
+
131
+ private group(condition: SeedConditionInput, body: () => void): void {
132
+ const operations: SeedOperation[] = []
133
+ this.stack.push(operations)
134
+ try {
135
+ body()
136
+ } finally {
137
+ // Popped in a `finally` so a seed that throws inside a `$if` does not leave every
138
+ // later operation collecting into a block that is never emitted.
139
+ this.stack.pop()
140
+ }
141
+ this.push({ op: "group", if: encodeCondition(condition), operations })
142
+ }
143
+
144
+ private push(operation: SeedOperation): void {
145
+ const block = this.stack[this.stack.length - 1]
146
+ if (block === undefined) throw new Error("the seed collector lost its block")
147
+ block.push(operation)
148
+ }
149
+
150
+ // --- References ---
151
+
152
+ private name(pending: Pending, label?: string): string {
153
+ if (pending.label !== undefined) return pending.label
154
+ this.labels += 1
155
+ pending.label = label ?? `op${this.labels}`
156
+ const operation = pending.operation
157
+ if (operation.op === "create" || operation.op === "upsert") {
158
+ operation.id = pending.label
159
+ }
160
+ return pending.label
161
+ }
162
+
163
+ /**
164
+ * What an operation hands back.
165
+ *
166
+ * A proxy rather than a plain object, because the columns a seed may reference are the
167
+ * model's columns and building one property per column per row would cost more than the
168
+ * seed itself. Reading a property is also what tells the collector this operation needs a
169
+ * label at all.
170
+ */
171
+ private reference(pending: Pending): Record<string, RefValue> {
172
+ return new Proxy({} as Record<string, RefValue>, {
173
+ get: (_target, property): RefValue | undefined => {
174
+ // A proxy gets asked about `then` when it is awaited or resolved, and about
175
+ // symbols by anything that inspects it. Answering with a reference would make a
176
+ // seed that awaits a builder call hang on a thenable that never settles.
177
+ if (typeof property !== "string" || property === "then") return undefined
178
+ return { $ref: `${this.name(pending)}.${property}` }
179
+ },
180
+ })
181
+ }
182
+
183
+ // --- Values ---
184
+
185
+ private encodeRow(entry: ModelManifest, row: SeedData): SeedData {
186
+ const out: SeedData = {}
187
+ for (const [key, value] of Object.entries(row)) {
188
+ if (value === undefined) continue
189
+ out[key] = this.encode(entry, key, value)
190
+ }
191
+ return out
192
+ }
193
+
194
+ private encode(entry: ModelManifest, key: string, value: SeedValue): SeedValue {
195
+ if (isPassThrough(value)) return value
196
+
197
+ const relation = entry.relations[key]
198
+ if (relation !== undefined) {
199
+ const nested = this.nested(relation.target, value)
200
+ if (nested !== undefined) return nested
201
+ }
202
+
203
+ if (value instanceof Date) return value.toISOString()
204
+ if (typeof value === "bigint") return (value as bigint).toString()
205
+
206
+ // The column's kind decides the encoding, and only the manifest knows it: a decimal
207
+ // sent as a JSON number round-trips through a double and stops being the value the
208
+ // column holds.
209
+ if (typeof value === "number" && entry.numeric.includes(key)) {
210
+ return { $numeric: String(value) } satisfies NumericValue
211
+ }
212
+ if (typeof value === "string" && entry.numeric.includes(key)) {
213
+ return { $numeric: value } satisfies NumericValue
214
+ }
215
+
216
+ if (Array.isArray(value)) return value.map((item) => this.encodeLoose(item))
217
+ return value
218
+ }
219
+
220
+ /**
221
+ * A relation's input, with its rows encoded against the model they will be written to.
222
+ *
223
+ * Returns `undefined` when the value is not one of the relation shapes, so an ordinary
224
+ * value on a field that happens to share a relation's name is left alone.
225
+ */
226
+ private nested(target: string, value: SeedValue): SeedValue | undefined {
227
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined
228
+ const shape = value as Record<string, unknown>
229
+ const entry = this.byModel.get(target)
230
+
231
+ if ("connect" in shape) {
232
+ const where = shape["connect"]
233
+ if (entry === undefined || where === null || typeof where !== "object") return undefined
234
+ return { connect: this.encodeRow(entry, where as SeedData) }
235
+ }
236
+ if ("create" in shape) {
237
+ const rows = toRows(shape["create"])
238
+ if (entry === undefined || rows === undefined) return undefined
239
+ return { create: rows.map((row) => this.encodeRow(entry, row)) }
240
+ }
241
+ if ("createMany" in shape) {
242
+ const rows = toRows(shape["createMany"])
243
+ if (entry === undefined || rows === undefined) return undefined
244
+ return { createMany: rows.map((row) => this.encodeRow(entry, row)) }
245
+ }
246
+ return undefined
247
+ }
248
+
249
+ /** A value with no column to consult, such as an element of an array. */
250
+ private encodeLoose(value: SeedValue): SeedValue {
251
+ if (isPassThrough(value)) return value
252
+ if (value instanceof Date) return value.toISOString()
253
+ if (typeof value === "bigint") return (value as bigint).toString()
254
+ if (Array.isArray(value)) return value.map((item) => this.encodeLoose(item))
255
+ return value
256
+ }
257
+ }
258
+
259
+ /**
260
+ * A `hasOne` nests one row and a `hasMany` nests several; the IR carries a list either way.
261
+ *
262
+ * Normalised here rather than in the generated builder, so six more generators do not each
263
+ * have to remember it.
264
+ */
265
+ function toRows(value: unknown): SeedData[] | undefined {
266
+ if (Array.isArray(value)) return value as SeedData[]
267
+ if (value !== null && typeof value === "object") return [value as SeedData]
268
+ return undefined
269
+ }
270
+
271
+ /**
272
+ * Values that are already in the shape the engine reads.
273
+ *
274
+ * `expr.now()` is literally `{ kind: "now" }` and a reference is literally `{ $ref: ... }`,
275
+ * so encoding them again would wrap an operand in a decimal or walk into a proxy.
276
+ */
277
+ function isPassThrough(value: SeedValue): boolean {
278
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
279
+ const shape = value as Record<string, unknown>
280
+ return (
281
+ "$ref" in shape ||
282
+ "$numeric" in shape ||
283
+ "$default" in shape ||
284
+ typeof shape["kind"] === "string"
285
+ )
286
+ }
287
+
288
+ /** The surface a seed writes, in the shape the IR carries. */
289
+ export function encodeCondition(condition: SeedConditionInput): SeedCondition {
290
+ if ("environment" in condition) {
291
+ return { kind: "environment", name: condition.environment }
292
+ }
293
+ if ("exists" in condition) {
294
+ return scoped("exists", condition.exists)
295
+ }
296
+ return scoped("notExists", condition.notExists)
297
+ }
298
+
299
+ function scoped(
300
+ kind: "exists" | "notExists",
301
+ target: { model: string; where?: Predicate },
302
+ ): SeedCondition {
303
+ return {
304
+ kind,
305
+ model: target.model,
306
+ ...(target.where !== undefined ? { where: target.where } : {}),
307
+ }
308
+ }
309
+
310
+ /** Re-exported for the runner, which has no reason to know these came from `seed.ts`. */
311
+ export type { DefaultExpr, Operand, RefValue, SeedIr, SeedOperation, SeedValue }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Run one seed module and take the document it described.
3
+ *
4
+ * In a child process, through tsx, which is how this CLI already evaluates a project's
5
+ * TypeScript. Two reasons beyond consistency: a seed is the user's code and should not share
6
+ * a process with the tool running it, and the same route works for the standalone binary,
7
+ * which interprets TypeScript itself rather than resolving tsx from a `node_modules` it does
8
+ * not have.
9
+ *
10
+ * The document comes back through a file rather than stdout, because a seed is allowed to
11
+ * print. Mixing its output into the channel carrying the document would mean a `log()` call
12
+ * could corrupt the IR.
13
+ */
14
+
15
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
16
+ import { tmpdir } from "node:os"
17
+ import { join } from "node:path"
18
+ import { pathToFileURL } from "node:url"
19
+ import { evalTsSnippet } from "./tsx-runner.js"
20
+ import type { SeedConfig, SeedIr } from "./seed.js"
21
+
22
+ /** What a seed module turned out to be. */
23
+ export type Collected =
24
+ | {
25
+ shape: "collected"
26
+ ir: SeedIr
27
+ config: SeedConfig
28
+ }
29
+ | {
30
+ /**
31
+ * The old contract: no default export, so importing it did the work.
32
+ *
33
+ * Every existing project is this shape and keeps working. Nothing here removes it.
34
+ */
35
+ shape: "legacy"
36
+ }
37
+
38
+ export interface CollectOptions {
39
+ cwd: string
40
+ /** The generated builder, for the manifest and the schema fingerprint. */
41
+ builderPath: string
42
+ environment?: string | undefined
43
+ }
44
+
45
+ export class SeedCollectionFailed extends Error {
46
+ constructor(
47
+ readonly file: string,
48
+ readonly output: string,
49
+ ) {
50
+ super(`${file} failed while it was being read:\n${output}`)
51
+ this.name = "SeedCollectionFailed"
52
+ }
53
+ }
54
+
55
+ /** Import `file`, run its default export against a collector, and return what it described. */
56
+ export function collectSeed(file: string, options: CollectOptions): { result: Collected; stdout: string } {
57
+ const scratch = mkdtempSync(join(tmpdir(), "supatype-seed-"))
58
+ const outPath = join(scratch, "ir.json")
59
+
60
+ try {
61
+ const run = evalTsSnippet(snippet(file, outPath, options), { cwd: options.cwd })
62
+ if (run.exitCode !== 0 || !existsSync(outPath)) {
63
+ throw new SeedCollectionFailed(file, [run.stdout, run.stderr].filter(Boolean).join("\n").trim())
64
+ }
65
+ const result = JSON.parse(readFileSync(outPath, "utf8")) as Collected
66
+ return { result, stdout: run.stdout }
67
+ } finally {
68
+ rmSync(scratch, { recursive: true, force: true })
69
+ }
70
+ }
71
+
72
+ /**
73
+ * The program that runs in the child.
74
+ *
75
+ * Every path is interpolated as a JSON string rather than pasted in, because a Windows path
76
+ * is full of backslashes and a project directory is allowed to contain a quote.
77
+ */
78
+ function snippet(file: string, outPath: string, options: CollectOptions): string {
79
+ const url = (path: string): string => JSON.stringify(pathToFileURL(path).href)
80
+ return `
81
+ import { writeFileSync } from "node:fs"
82
+ import { SeedCollector } from "./seed-ir.js"
83
+ import { expr } from "./seed.js"
84
+
85
+ const builder = await import(${url(options.builderPath)})
86
+ const module = await import(${url(file)})
87
+
88
+ if (typeof module.default !== "function") {
89
+ // Importing it is what ran it, which is the old contract working exactly as it did.
90
+ writeFileSync(${JSON.stringify(outPath)}, JSON.stringify({ shape: "legacy" }))
91
+ } else {
92
+ const collector = new SeedCollector(builder.schemaFingerprint, builder.seedModels)
93
+ await module.default({
94
+ db: collector.db(),
95
+ expr,
96
+ log: (message) => { console.log(message) },
97
+ environment: ${options.environment === undefined ? "undefined" : JSON.stringify(options.environment)},
98
+ })
99
+ writeFileSync(
100
+ ${JSON.stringify(outPath)},
101
+ JSON.stringify({
102
+ shape: "collected",
103
+ ir: collector.finish().ir,
104
+ config: module.config ?? {},
105
+ }),
106
+ )
107
+ }
108
+ `
109
+ }