@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
@@ -227,6 +227,15 @@ export interface ExtractedSchemaAstV2 {
227
227
  }
228
228
  }
229
229
 
230
+ /**
231
+ * The Postgres type each field kind maps to, as published in the AST.
232
+ *
233
+ * A contract, not a hint: consumers generate parsers and casts from it. `money` and `decimal` said
234
+ * `TEXT` here while the engine creates `NUMERIC(19,4)` and `NUMERIC`, so anyone reading this wrote
235
+ * code that rejected every value the column returns. The engine's own `default_pg_type` agreed
236
+ * with the mistake for `money`, and was already right for `decimal`, which is what made it look
237
+ * considered.
238
+ */
230
239
  const DEFAULT_DB_BY_KIND: Partial<Record<FieldKind, Partial<DbFieldAnnotations>>> = {
231
240
  text: { pgType: "TEXT" },
232
241
  richText: { pgType: "JSONB" },
@@ -244,17 +253,17 @@ const DEFAULT_DB_BY_KIND: Partial<Record<FieldKind, Partial<DbFieldAnnotations>>
244
253
  slug: { pgType: "TEXT" },
245
254
  enum: { pgType: "TEXT" },
246
255
  json: { pgType: "JSONB" },
247
- decimal: { pgType: "TEXT" },
256
+ decimal: { pgType: "NUMERIC" },
248
257
  bytes: { pgType: "BYTEA" },
249
258
  serial: { pgType: "SERIAL" },
250
259
  bigSerial: { pgType: "BIGSERIAL" },
251
- money: { pgType: "TEXT" },
252
- ip: { pgType: "TEXT" },
253
- cidr: { pgType: "TEXT" },
254
- macaddr: { pgType: "TEXT" },
255
- xml: { pgType: "TEXT" },
256
- tsQuery: { pgType: "TEXT" },
257
- tsVector: { pgType: "TEXT" },
260
+ money: { pgType: "NUMERIC(19,4)" },
261
+ ip: { pgType: "INET" },
262
+ cidr: { pgType: "CIDR" },
263
+ macaddr: { pgType: "MACADDR" },
264
+ xml: { pgType: "XML" },
265
+ tsQuery: { pgType: "TSQUERY" },
266
+ tsVector: { pgType: "TSVECTOR" },
258
267
  color: { pgType: "TEXT" },
259
268
  array: { pgType: "ARRAY" },
260
269
  image: { pgType: "JSONB" },
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Which database a seed run targets, and how that was decided.
3
+ *
4
+ * Its own file so the failure is testable without spawning anything. The failure is the
5
+ * point: the old seed template carried a hardcoded fallback DSN, so a project with no
6
+ * `.env` did not fail, it silently tried `localhost:5432` with a guessed password and
7
+ * reported a connection error that named none of the things it had looked at. Someone
8
+ * reading that has no way to tell "I have not set DATABASE_URL" from "the stack is not
9
+ * running".
10
+ *
11
+ * So every source is recorded whether it was found or not, and the error prints the lot.
12
+ */
13
+
14
+ import { existsSync } from "node:fs"
15
+ import { join } from "node:path"
16
+ import { readEnvFile } from "./env-file.js"
17
+ import {
18
+ externalDatabaseUrl,
19
+ localDSN,
20
+ projectRootFromConfig,
21
+ type SupatypeProjectConfig,
22
+ } from "./project-config.js"
23
+
24
+ /** One place a connection string could have come from. */
25
+ export interface DsnSource {
26
+ /** How it would be described to the person running the command. */
27
+ name: string
28
+ /** What was found there, or why nothing was. */
29
+ detail: string
30
+ found: boolean
31
+ }
32
+
33
+ export interface ResolvedDsn {
34
+ dsn: string
35
+ /** The `name` of the source it came from. */
36
+ source: string
37
+ /** Every source, in the order they were considered. */
38
+ tried: readonly DsnSource[]
39
+ }
40
+
41
+ /** Nothing named a database, with every source tried. */
42
+ export class DsnNotFound extends Error {
43
+ constructor(readonly tried: readonly DsnSource[]) {
44
+ super(
45
+ `No database connection string for this seed run.\n${tried
46
+ .map((source) => ` ${source.found ? "found" : "not set"} ${source.name}: ${source.detail}`)
47
+ .join("\n")}`,
48
+ )
49
+ this.name = "DsnNotFound"
50
+ }
51
+ }
52
+
53
+ export interface ResolveOptions {
54
+ /** `--connection`, which beats everything because it was typed just now. */
55
+ connection?: string | undefined
56
+ /**
57
+ * Whether to fall back to the DSN derived from the project name.
58
+ *
59
+ * True for a project whose database Supatype manages, where the derived value is right by
60
+ * construction. A project pointed at someone else's Postgres has no such default, and
61
+ * inventing one would send the seed to a database nobody named.
62
+ */
63
+ allowDerived?: boolean
64
+ }
65
+
66
+ /**
67
+ * Resolve the DSN, in the order the rest of the CLI already uses.
68
+ *
69
+ * `.env` is loaded first but ranks below a real environment variable, which is how every
70
+ * other dotenv reader behaves and how Compose resolves the same names.
71
+ */
72
+ export function resolveSeedDsn(
73
+ cwd: string,
74
+ config: SupatypeProjectConfig,
75
+ options: ResolveOptions = {},
76
+ ): ResolvedDsn {
77
+ const root = projectRootFromConfig(config, cwd)
78
+ const envPath = join(root, ".env")
79
+ const fromFile = readEnvFile(root)
80
+ const tried: DsnSource[] = []
81
+
82
+ const candidate = (name: string, value: string | undefined, absent: string): string | undefined => {
83
+ const trimmed = value?.trim()
84
+ if (trimmed !== undefined && trimmed.length > 0) {
85
+ tried.push({ name, detail: redact(trimmed), found: true })
86
+ return trimmed
87
+ }
88
+ tried.push({ name, detail: absent, found: false })
89
+ return undefined
90
+ }
91
+
92
+ const flag = candidate("--connection", options.connection, "not passed")
93
+ if (flag !== undefined) return { dsn: flag, source: "--connection", tried }
94
+
95
+ // Ahead of the rest on purpose: a stated external database is the whole stack's database,
96
+ // and seeding somewhere else while the services read from here would look like data loss.
97
+ const external = candidate(
98
+ "database.external.url in supatype.config.ts",
99
+ externalDatabaseUrl(config),
100
+ "not declared",
101
+ )
102
+ if (external !== undefined) {
103
+ return { dsn: external, source: "database.external.url in supatype.config.ts", tried }
104
+ }
105
+
106
+ const declared = candidate("connection in supatype.config.ts", config.connection, "not declared")
107
+ if (declared !== undefined) {
108
+ return { dsn: declared, source: "connection in supatype.config.ts", tried }
109
+ }
110
+
111
+ const fromEnv = candidate("DATABASE_URL in the environment", process.env["DATABASE_URL"], "not set")
112
+ if (fromEnv !== undefined) {
113
+ return { dsn: fromEnv, source: "DATABASE_URL in the environment", tried }
114
+ }
115
+
116
+ const fileLabel = `DATABASE_URL in ${envPath}`
117
+ const fileValue = candidate(
118
+ fileLabel,
119
+ fromFile["DATABASE_URL"],
120
+ existsSync(envPath) ? "the file has no DATABASE_URL" : "no .env file",
121
+ )
122
+ if (fileValue !== undefined) return { dsn: fileValue, source: fileLabel, tried }
123
+
124
+ if (options.allowDerived === true) {
125
+ const derived = localDSN(config)
126
+ tried.push({ name: "the project's own local database", detail: redact(derived), found: true })
127
+ return { dsn: derived, source: "the project's own local database", tried }
128
+ }
129
+ tried.push({
130
+ name: "the project's own local database",
131
+ detail: "this project points at a database it does not manage, so there is no default",
132
+ found: false,
133
+ })
134
+
135
+ throw new DsnNotFound(tried)
136
+ }
137
+
138
+ /**
139
+ * A connection string with its password removed.
140
+ *
141
+ * Every one of these reaches a terminal, and often a CI log. Printing a DSN to explain where
142
+ * it came from should not be how a password ends up somewhere it is kept.
143
+ */
144
+ export function redact(dsn: string): string {
145
+ return dsn.replace(/(:\/\/[^:/@]+:)[^@]*(@)/, "$1***$2")
146
+ }
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
+ }