@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
package/src/seed.ts CHANGED
@@ -1,3 +1,22 @@
1
+ /**
2
+ * What a project's seed file imports.
3
+ *
4
+ * Two contracts live here. `sql()` is the old one: the author opens a connection, writes
5
+ * statements and closes it. It is unchanged and still supported, because every existing
6
+ * project uses it.
7
+ *
8
+ * The rest is the new one. A seed exports a function, the runner hands it a `db` typed from
9
+ * the project's schema, and the calls it makes are **collected, not executed**: they become
10
+ * a seed IR document that the engine validates, orders, batches and runs in one
11
+ * transaction.
12
+ *
13
+ * **Nothing here connects to a database, and nothing here builds SQL.** That is the point of
14
+ * the split: the hard parts, ordering a foreign-key graph, batching, mapping a Postgres
15
+ * error back to the schema, are written once in the engine rather than once per language.
16
+ * This file is the part that has to exist per language, and it is deliberately small enough
17
+ * to be worth writing seven times.
18
+ */
19
+
1
20
  import pg from "pg"
2
21
 
3
22
  export interface SeedSql {
@@ -41,3 +60,392 @@ export function sql(connectionString: string): SeedSql {
41
60
  },
42
61
  })
43
62
  }
63
+
64
+ // --- The expression language ---
65
+
66
+ /**
67
+ * A value the database computes, rather than one the seed hardcodes.
68
+ *
69
+ * The same operand set the schema's access rules use, serialised the same way. Not a second
70
+ * vocabulary: forking one for seeds would need a second validator and a second SQL
71
+ * renderer, and would re-litigate decisions the engine has already made and documented.
72
+ */
73
+ export type Operand<C extends string = string> =
74
+ | { kind: "literal"; value: LiteralValue }
75
+ | { kind: "column"; name: C }
76
+ | { kind: "now" }
77
+ | { kind: "startOf"; unit: TruncUnit }
78
+ | { kind: "ago"; amount: number; unit: TimeUnit }
79
+ | { kind: "fromNow"; amount: number; unit: TimeUnit }
80
+ | { kind: "after"; base: Operand<C>; interval: IntervalSpec }
81
+ | { kind: "before"; base: Operand<C>; interval: IntervalSpec }
82
+
83
+ export type LiteralValue = string | number | boolean
84
+
85
+ export type TruncUnit = "day" | "week" | "month" | "year"
86
+
87
+ export type TimeUnit = "seconds" | "minutes" | "hours" | "days" | "weeks" | "months" | "years"
88
+
89
+ /**
90
+ * A span of time, in any combination of units.
91
+ *
92
+ * Multi-unit because a fixture calendar wants "thirty days from now at 09:00", which a
93
+ * single `{ amount, unit }` cannot say. Components are non-negative: the direction lives in
94
+ * the operand's name, so that `ago` reads as itself rather than as a double negative.
95
+ */
96
+ export interface IntervalSpec {
97
+ years?: number
98
+ months?: number
99
+ weeks?: number
100
+ days?: number
101
+ hours?: number
102
+ minutes?: number
103
+ seconds?: number
104
+ }
105
+
106
+ /**
107
+ * A time the database evaluates.
108
+ *
109
+ * Its own type so a generated builder can accept it on date-like columns and nowhere else:
110
+ * `expr.now()` assigned to a title does not compile.
111
+ */
112
+ export type TimeExpr = Extract<
113
+ Operand,
114
+ { kind: "now" | "startOf" | "ago" | "fromNow" | "after" | "before" }
115
+ >
116
+
117
+ /**
118
+ * The column's own default.
119
+ *
120
+ * Distinct from leaving the field out. Omission means "do not write this column", which on
121
+ * the update half of an upsert keeps the existing value; this means "put it back to the
122
+ * default". Both are wanted and only one can be spelled by absence.
123
+ */
124
+ export interface DefaultExpr {
125
+ readonly $default: true
126
+ }
127
+
128
+ const DEFAULT_EXPR: DefaultExpr = { $default: true }
129
+
130
+ const INTERVAL_UNITS = [
131
+ "years",
132
+ "months",
133
+ "weeks",
134
+ "days",
135
+ "hours",
136
+ "minutes",
137
+ "seconds",
138
+ ] as const
139
+
140
+ /**
141
+ * Drop the units nobody asked for, so two equal intervals serialise the same way.
142
+ *
143
+ * A negative or fractional component is refused rather than carried. The engine's interval
144
+ * components are whole and unsigned, so one that is not fails to read as an operand, falls
145
+ * through to being a plain value, and reaches the database as JSON text in a date column.
146
+ * That surfaced as `invalid input syntax for type timestamp with time zone`, naming neither
147
+ * the field nor the real mistake. Here it names both, at the call that made it.
148
+ */
149
+ function interval(spec: IntervalSpec): IntervalSpec {
150
+ const out: IntervalSpec = {}
151
+ for (const unit of INTERVAL_UNITS) {
152
+ const value = spec[unit]
153
+ if (value === undefined || value === 0) continue
154
+ if (!Number.isInteger(value) || value < 0) {
155
+ throw new TypeError(
156
+ `An interval's \`${unit}\` must be a whole number and not negative, and this is ${value}. ` +
157
+ "The direction is in the name: use `ago` or `before` to go backwards.",
158
+ )
159
+ }
160
+ out[unit] = value
161
+ }
162
+ return out
163
+ }
164
+
165
+ function shifted(base: Operand, spec: IntervalSpec | undefined, ahead: boolean): TimeExpr {
166
+ if (spec === undefined) return base as TimeExpr
167
+ const amount = interval(spec)
168
+ if (Object.keys(amount).length === 0) return base as TimeExpr
169
+ return ahead
170
+ ? { kind: "after", base, interval: amount }
171
+ : { kind: "before", base, interval: amount }
172
+ }
173
+
174
+ /**
175
+ * Values the database computes.
176
+ *
177
+ * Deliberately database-side rather than computed here. A client-computed `daysFromNow(30)`
178
+ * produces a different instant on every run, so a builder emitting it could never reproduce
179
+ * a checked-in fixture; and one IR applied to several environments at different times would
180
+ * give every one of them the same hardcoded instant, which for a fixture calendar is never
181
+ * what people mean.
182
+ */
183
+ export const expr = {
184
+ /** `now()`, optionally shifted forward. */
185
+ now(after?: IntervalSpec): TimeExpr {
186
+ return shifted({ kind: "now" }, after, true)
187
+ },
188
+ /** `now()` shifted back. */
189
+ ago(before: IntervalSpec): TimeExpr {
190
+ return shifted({ kind: "now" }, before, false)
191
+ },
192
+ /** The start of the current day, week, month or year, optionally shifted forward. */
193
+ startOf(unit: TruncUnit, after?: IntervalSpec): TimeExpr {
194
+ return shifted({ kind: "startOf", unit }, after, true)
195
+ },
196
+ /** The start of a period, shifted back. */
197
+ startOfBefore(unit: TruncUnit, amount: IntervalSpec): TimeExpr {
198
+ return shifted({ kind: "startOf", unit }, amount, false)
199
+ },
200
+ /** The column's own default. */
201
+ default(): DefaultExpr {
202
+ return DEFAULT_EXPR
203
+ },
204
+ } as const
205
+
206
+ // --- Predicates ---
207
+
208
+ export type CompareOp = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "like"
209
+
210
+ /**
211
+ * A condition on rows, in the schema's own rule language.
212
+ *
213
+ * The engine already parses, validates and compiles this tree for every RLS policy it
214
+ * writes, so a seed condition is that tree scoped to a model rather than anything new. A
215
+ * person who has written `Eq<"status", Literal<"published">>` in their schema already knows
216
+ * this language.
217
+ */
218
+ export type Predicate<C extends string = string> =
219
+ | { type: "compare"; left: Operand<C>; op: CompareOp; right: Operand<C> }
220
+ | { type: "any"; rules: Predicate<C>[] }
221
+ | { type: "all"; rules: Predicate<C>[] }
222
+ | { type: "not"; rule: Predicate<C> }
223
+ | { type: "nullCheck"; operand: Operand<C>; isNull: boolean }
224
+ | { type: "in"; column: C; source: { kind: "literal"; values: LiteralValue[] } }
225
+
226
+ function compare<C extends string>(column: C, op: CompareOp, value: LiteralValue): Predicate<C> {
227
+ return {
228
+ type: "compare",
229
+ left: { kind: "column", name: column },
230
+ op,
231
+ right: { kind: "literal", value },
232
+ }
233
+ }
234
+
235
+ export const eq = <C extends string>(column: C, value: LiteralValue): Predicate<C> =>
236
+ compare(column, "eq", value)
237
+ export const neq = <C extends string>(column: C, value: LiteralValue): Predicate<C> =>
238
+ compare(column, "neq", value)
239
+ export const gt = <C extends string>(column: C, value: LiteralValue): Predicate<C> =>
240
+ compare(column, "gt", value)
241
+ export const gte = <C extends string>(column: C, value: LiteralValue): Predicate<C> =>
242
+ compare(column, "gte", value)
243
+ export const lt = <C extends string>(column: C, value: LiteralValue): Predicate<C> =>
244
+ compare(column, "lt", value)
245
+ export const lte = <C extends string>(column: C, value: LiteralValue): Predicate<C> =>
246
+ compare(column, "lte", value)
247
+ export const like = <C extends string>(column: C, pattern: string): Predicate<C> =>
248
+ compare(column, "like", pattern)
249
+
250
+ export const isNull = <C extends string>(column: C): Predicate<C> => ({
251
+ type: "nullCheck",
252
+ operand: { kind: "column", name: column },
253
+ isNull: true,
254
+ })
255
+ export const notNull = <C extends string>(column: C): Predicate<C> => ({
256
+ type: "nullCheck",
257
+ operand: { kind: "column", name: column },
258
+ isNull: false,
259
+ })
260
+
261
+ /** Every rule must hold. */
262
+ export const and = <C extends string>(...rules: Predicate<C>[]): Predicate<C> => ({
263
+ type: "all",
264
+ rules,
265
+ })
266
+
267
+ /** Any one rule holding is enough. */
268
+ export const or = <C extends string>(...rules: Predicate<C>[]): Predicate<C> => ({
269
+ type: "any",
270
+ rules,
271
+ })
272
+
273
+ export const not = <C extends string>(rule: Predicate<C>): Predicate<C> => ({ type: "not", rule })
274
+
275
+ /**
276
+ * The column's value is one of these.
277
+ *
278
+ * `oneOf` rather than `in`, which is a reserved word and cannot be an identifier.
279
+ */
280
+ export const oneOf = <C extends string>(
281
+ column: C,
282
+ values: readonly LiteralValue[],
283
+ ): Predicate<C> => ({
284
+ type: "in",
285
+ column,
286
+ source: { kind: "literal", values: [...values] },
287
+ })
288
+
289
+ // --- The document a builder produces ---
290
+
291
+ /** A value produced by an earlier operation in the same document. */
292
+ export interface RefValue {
293
+ readonly $ref: string
294
+ }
295
+
296
+ /** An exact decimal, kept as text because JSON has no decimal type. */
297
+ export interface NumericValue {
298
+ readonly $numeric: string
299
+ }
300
+
301
+ export type SeedValue =
302
+ | string
303
+ | number
304
+ | boolean
305
+ | null
306
+ | readonly SeedValue[]
307
+ | RefValue
308
+ | NumericValue
309
+ | DefaultExpr
310
+ | Operand
311
+ | { readonly [key: string]: SeedValue | undefined }
312
+
313
+ export type SeedData = Record<string, SeedValue>
314
+
315
+ export type SeedOperation =
316
+ | { op: "create"; model: string; id?: string; data: SeedData }
317
+ | { op: "createMany"; model: string; rows: SeedData[] }
318
+ | { op: "upsert"; model: string; id?: string; where: SeedData; data: SeedData }
319
+ | { op: "sql"; text: string; params?: SeedValue[] }
320
+ | { op: "group"; if?: SeedCondition; operations: SeedOperation[] }
321
+
322
+ export type SeedCondition =
323
+ | { kind: "exists"; model: string; where?: Predicate }
324
+ | { kind: "notExists"; model: string; where?: Predicate }
325
+ | { kind: "environment"; name: string }
326
+
327
+ /** One seed document, as the engine reads it. */
328
+ export interface SeedIr {
329
+ irVersion: 1
330
+ schemaFingerprint: string
331
+ operations: SeedOperation[]
332
+ }
333
+
334
+ // --- What a generated builder plugs into ---
335
+
336
+ /**
337
+ * What the runtime needs to know about a model and cannot tell from a value.
338
+ *
339
+ * Written by the generator, read by the collector. Three facts, each of which changes how a
340
+ * value is encoded and none of which is visible at runtime: which columns are exact
341
+ * decimals, which hold a time, and which fields are relations to what.
342
+ */
343
+ export interface ModelManifest {
344
+ model: string
345
+ table: string
346
+ /** Columns where a number would lose precision, so the value travels as an exact decimal. */
347
+ numeric: readonly string[]
348
+ /** Columns that hold an instant, so a `Date` is rendered as one. */
349
+ temporal: readonly string[]
350
+ relations: Readonly<Record<string, { kind: RelationKind; target: string }>>
351
+ }
352
+
353
+ export type RelationKind = "belongsTo" | "hasOne" | "hasMany" | "manyToMany"
354
+
355
+ /** What a model exposes on `db`. */
356
+ export interface ModelApi<TCreate, TWhere, TRef> {
357
+ /** Insert one row, and hand back something later operations can point at. */
358
+ create(data: TCreate, options?: { id?: string }): TRef
359
+ /**
360
+ * Insert many rows of one model in one statement.
361
+ *
362
+ * Not sugar for repeated `create`: a per-row API is how a seed of a few thousand rows
363
+ * becomes a few thousand round trips.
364
+ */
365
+ createMany(rows: readonly TCreate[]): void
366
+ /**
367
+ * Insert, or update the row the `where` columns already identify.
368
+ *
369
+ * The primary operation for a seed, because seeds get re-run and idempotency is the thing
370
+ * every hand-written one gets wrong.
371
+ */
372
+ upsert(args: { where: TWhere; data: TCreate; id?: string }): TRef
373
+ }
374
+
375
+ /**
376
+ * Filled in by the generated builder, through declaration merging.
377
+ *
378
+ * Empty here so the runtime compiles on its own, and so a project that has not run
379
+ * `supatype generate` yet gets a loosely typed `db` rather than a module that does not
380
+ * resolve.
381
+ */
382
+ export interface SupatypeSeedRegistry {
383
+ /** Never set. Present so the interface is not empty, which some lint rules refuse. */
384
+ readonly __generated?: never
385
+ }
386
+
387
+ type FallbackModel = ModelApi<SeedData, SeedData, Record<string, RefValue>>
388
+
389
+ type RegisteredModels = SupatypeSeedRegistry extends { models: infer M }
390
+ ? M
391
+ : Record<string, FallbackModel>
392
+
393
+ type RegisteredCondition = SupatypeSeedRegistry extends { condition: infer C }
394
+ ? C
395
+ : SeedConditionInput
396
+
397
+ /** The condition surface, before a generated builder narrows it to this schema's models. */
398
+ export type SeedConditionInput =
399
+ | { exists: { model: string; where?: Predicate } }
400
+ | { notExists: { model: string; where?: Predicate } }
401
+ | { environment: string }
402
+
403
+ export type SeedDb = RegisteredModels & {
404
+ /**
405
+ * Run a block only when the database says so.
406
+ *
407
+ * Plain `if` already works for anything decided from the program's own inputs, and should
408
+ * be preferred. This is for the one thing plain `if` cannot do: branch on database state,
409
+ * which the builder cannot read because it never connects. The condition travels in the
410
+ * IR and the engine evaluates it at this position, inside the transaction.
411
+ */
412
+ $if(condition: RegisteredCondition, body: () => void): void
413
+ /**
414
+ * A raw statement, in the same transaction and the same order as everything else.
415
+ *
416
+ * Inside the IR rather than offered as a side channel, so a seed that mixes typed
417
+ * operations and raw SQL still runs as one thing instead of the author opening a second
418
+ * connection the engine knows nothing about.
419
+ */
420
+ $sql(text: string, params?: readonly SeedValue[]): void
421
+ }
422
+
423
+ /** What the runner hands a seed's default export. */
424
+ export interface SeedContext {
425
+ db: SeedDb
426
+ expr: typeof expr
427
+ /** Print a line, through the runner's own output rather than straight to stdout. */
428
+ log: (message: string) => void
429
+ /** The environment this run targets, when the runner was told one. */
430
+ environment?: string | undefined
431
+ }
432
+
433
+ /** What a seed file may ask of the runner. */
434
+ export interface SeedConfig {
435
+ /** Run this document in a transaction. Default true, and rarely worth changing. */
436
+ transaction?: boolean
437
+ /**
438
+ * Apply this document at most once per environment.
439
+ *
440
+ * For a genuine one-time backfill rather than a fixture. Off by default, because seeds
441
+ * are idempotent upserts by design and skipping one because it ran before would turn
442
+ * every seed file into a one-shot migration.
443
+ */
444
+ runOnce?: boolean
445
+ }
446
+
447
+ /** The shape a seed module exports. */
448
+ export interface SeedModule {
449
+ default?: (context: SeedContext) => void | Promise<void>
450
+ config?: SeedConfig
451
+ }
@@ -14,9 +14,10 @@ import {
14
14
  usesExternalDatabase,
15
15
  type SupatypeProjectConfig,
16
16
  } from "./project-config.js"
17
- import { hasEngineOverride, hasStudioOverride, pinnedVersion, fetchLatestVersion, VERSION_PIN_LOCAL } from "./binary-cache.js"
17
+ import { hasStudioOverride, pinnedVersion, fetchLatestVersion, VERSION_PIN_LOCAL } from "./binary-cache.js"
18
18
  import { buildKongDeclarative } from "./kong-config.js"
19
19
  import { keyspaceInPostgres } from "./cache-provider.js"
20
+ import { STUDIO_DEV_PORT } from "./studio-dev-server.js"
20
21
  import { readEnvFile } from "./env-file.js"
21
22
  import { fieldMaskingTierFromProject, type FieldMaskingTier } from "./field-masking-tier.js"
22
23
  import { projectHasVersionedModels } from "./model-versioning.js"
@@ -285,8 +286,16 @@ function postgrestDatabaseUrl(config: SupatypeProjectConfig): string {
285
286
  return `postgresql://authenticator:${password}@${parsed.hostname}${port}${parsed.pathname}${parsed.search}`
286
287
  }
287
288
 
288
- /** Host Vite dev server as seen from Kong inside Docker Compose. */
289
- export const COMPOSE_STUDIO_HOST_URL = "http://host.docker.internal:3002"
289
+ /**
290
+ * Host Vite dev server as seen from Kong inside Docker Compose.
291
+ *
292
+ * Derived from STUDIO_DEV_PORT rather than repeating the number, because the two are one decision.
293
+ * Held separately they drift, and the drift is invisible: Vite binds the new port, Kong keeps
294
+ * proxying to the old one, and `/studio/` serves whatever else happens to be listening there. On
295
+ * the machine this was found, that was an unrelated Next.js app, and every Studio view failed with
296
+ * a missing sign-in form rather than anything naming a port.
297
+ */
298
+ export const COMPOSE_STUDIO_HOST_URL = `http://host.docker.internal:${STUDIO_DEV_PORT}`
290
299
 
291
300
  /** Studio container: always Docker Hub unless SUPATYPE_STUDIO_IMAGE is set in .env. */
292
301
  function studioServiceBlock(): string {
@@ -395,7 +404,18 @@ ${studioService}
395
404
  : ` - server
396
405
  - studio
397
406
  - control-plane`
398
- const publishDbToHost = !devLocal || hasEngineOverride(config)
407
+ // The database is always reachable from the host.
408
+ //
409
+ // It used to be published in dev only when something was known to need it, which was a host
410
+ // engine build or a project that had named `SUPATYPE_DEV_DB_PORT`. Seeding is now a host
411
+ // process: the CLI resolves a connection string and the engine connects over TCP. Under the old
412
+ // rule a fresh `supatype init` produced a stack whose database nothing on the host could reach,
413
+ // so the first `supatype seed` got ECONNREFUSED and the remedy was an environment variable
414
+ // nobody had reason to know about.
415
+ //
416
+ // This exposes nothing new: the dev port below is bound to `127.0.0.1`, so it is reachable from
417
+ // this machine and from nowhere else.
418
+ const publishDbToHost = true
399
419
  const dbPorts = publishDbToHost
400
420
  ? devLocal
401
421
  ? ` ports:
@@ -437,6 +457,15 @@ ${studioService}
437
457
  volumes:
438
458
  - storage-data:/data
439
459
  - ${SEAWEED_CONFIG_MOUNT}:/etc/seaweedfs/s3.json:ro
460
+ healthcheck:
461
+ # Any HTTP status line means the S3 endpoint is listening, which is the whole question.
462
+ # Success cannot be "HTTP 200": an unauthenticated GET on the root answers 403 by design,
463
+ # because the anonymous identity is deliberately absent from s3.json.
464
+ test: ["CMD-SHELL", "wget -q -S -O /dev/null http://127.0.0.1:8333 2>&1 | grep -q 'HTTP/'"]
465
+ interval: 3s
466
+ timeout: 3s
467
+ retries: 20
468
+ start_period: 5s
440
469
  ${seaweedPorts}`
441
470
  const kongTlsEnv = tlsEnabled
442
471
  ? ` KONG_PROXY_LISTEN: "0.0.0.0:8000, 0.0.0.0:8443 ssl"
@@ -491,6 +520,21 @@ ${keyspaceInPg ? "" : " valkey-data:\n"}`
491
520
  `
492
521
  const dbDependency = external ? "" : ` depends_on:\n${dbDependencyClause}`
493
522
 
523
+ // Storage waits for the object store, not only the database.
524
+ //
525
+ // seaweedfs had no healthcheck and nothing depended on it, so compose started it alongside
526
+ // storage rather than before it. Storage would then accept a bucket creation before seaweedfs
527
+ // was listening, and every bucket in the schema failed with `connect ECONNREFUSED <ip>:8333`
528
+ // while the metadata row was written anyway. Measured on a live stack: seaweedfs started twelve
529
+ // minutes after storage with RestartCount 0, so it was ordered late rather than crashing.
530
+ //
531
+ // A later push succeeds, which is exactly what made it read as an intermittent mystery. It
532
+ // lands on the first push after a stack comes up, which is the first push a new user ever runs.
533
+ const storageDependency = ` depends_on:
534
+ ${dbDependencyClause} seaweedfs:
535
+ condition: service_healthy
536
+ `
537
+
494
538
  // Realtime, omitted entirely when the project has turned it off.
495
539
  //
496
540
  // Not started-and-disabled: the service degrades gracefully on its own (it reports the reason on
@@ -676,7 +720,7 @@ ${dbDependency}
676
720
  S3_ACCESS_KEY: ${OBJECT_STORE_ACCESS_KEY}
677
721
  S3_SECRET_KEY: ${OBJECT_STORE_SECRET_KEY}
678
722
  S3_FORCE_PATH_STYLE: "true"
679
- ${dbDependency}
723
+ ${storageDependency}
680
724
  functions-worker:
681
725
  image: \${SUPATYPE_FUNCTIONS_WORKER_IMAGE:-supatype/functions-worker:latest}
682
726
  expose:
@@ -705,6 +749,18 @@ ${dbDependency}
705
749
  # each one able to read past every access rule in the schema.
706
750
  SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY:-}
707
751
  SUPATYPE_SERVICE_ROLE_ROUTES: "${serviceRoleRoutes(config).join(",")}"
752
+ # A direct database connection for functions, off unless asked for.
753
+ #
754
+ # The worker reads SUPATYPE_DB_URL and exposes it as \`ctx.dbUrl\`, and nothing here ever set
755
+ # it, so the field was permanently undefined on self-host and a function reaching for it got
756
+ # no value and no explanation.
757
+ #
758
+ # Deliberately its own variable rather than the project's DATABASE_URL. That one is the owner
759
+ # DSN every service already uses, and wiring it through by default would hand every function
760
+ # a connection that bypasses access rules, field masking and model hooks, none of which live
761
+ # in the database. Naming a separate variable makes it a decision: set it to the owner URL
762
+ # and accept that, or to a role you restricted yourself.
763
+ SUPATYPE_DB_URL: \${SUPATYPE_FUNCTIONS_DB_URL:-}
708
764
  STRIPE_SECRET_KEY: \${STRIPE_SECRET_KEY:-}
709
765
  STRIPE_WEBHOOK_SECRET: \${STRIPE_WEBHOOK_SECRET:-}
710
766
  SITE_URL: \${SITE_URL:-\${API_EXTERNAL_URL:-${externalUrlFallback}}}
@@ -730,6 +786,12 @@ ${realtimeBlock}
730
786
  ${dbDependency}
731
787
  server:
732
788
  image: \${SUPATYPE_SERVER_IMAGE:-\${SUPATYPE_AUTH_IMAGE:-supatype/server:latest}}
789
+ # host.docker.internal is a Docker Desktop name. On Linux it does not resolve unless it is
790
+ # mapped, so a project proxying the site or Studio to something on the host worked on macOS
791
+ # and Windows and failed on Linux with nothing reaching the app. host-gateway is Docker's own
792
+ # alias for the host, and needs 20.10, which this stack already requires.
793
+ extra_hosts:
794
+ - "host.docker.internal:host-gateway"
733
795
  # The server runs its migrations at boot on a connection of their own,
734
796
  # and that path does not wait out a database that is still in recovery:
735
797
  # it exits. Waiting for db to report healthy is not enough, because
@@ -798,7 +860,7 @@ ${appEnv}
798
860
  SUPATYPE_SMTP_ADMIN_EMAIL: \${SUPATYPE_SMTP_ADMIN_EMAIL:-}
799
861
  SUPATYPE_SMTP_SENDER_NAME: \${SUPATYPE_SMTP_SENDER_NAME:-}
800
862
  SUPATYPE_DISABLE_SIGNUP: \${DISABLE_SIGNUP:-false}
801
- ${devLocal ? " STUDIO_OPEN_DEV: \"1\"\n" : ""}
863
+ ${devLocal ? " STUDIO_OPEN_DEV: \"${STUDIO_OPEN_DEV:-1}\"\n" : ""}
802
864
  depends_on:
803
865
  ${dbDependencyClause}${keyspaceInPg ? "" : " valkey:\n condition: service_started\n"} postgrest:
804
866
  condition: service_started
@@ -819,6 +881,12 @@ ${objectStoreBlock}
819
881
  working_dir: /project
820
882
  ${dbDependency}${studioBlock}${valkeyBlock}${tlsHintComment} kong:
821
883
  image: kong:3.6
884
+ # host.docker.internal is a Docker Desktop name. On Linux it does not resolve unless it is
885
+ # mapped, so a project proxying the site or Studio to something on the host worked on macOS
886
+ # and Windows and failed on Linux with nothing reaching the app. host-gateway is Docker's own
887
+ # alias for the host, and needs 20.10, which this stack already requires.
888
+ extra_hosts:
889
+ - "host.docker.internal:host-gateway"
822
890
  environment:
823
891
  KONG_DATABASE: "off"
824
892
  KONG_DECLARATIVE_CONFIG: /etc/kong/kong.yml
package/src/strict.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Strict mode: a degraded path is a failure rather than a warning.
3
+ *
4
+ * Every fallback in this CLI exists because giving up would serve a person at a terminal worse
5
+ * than carrying on: the in-compose engine when the CDN cannot be reached, SHA256-only verification
6
+ * when no key is embedded, a bucket that could not be reached from the host but works from inside
7
+ * the network. Each is the right default interactively.
8
+ *
9
+ * In CI the trade runs the other way. A run that limps reports success, and the thing it quietly
10
+ * skipped is precisely the thing nobody tests again.
11
+ *
12
+ * This is not hypothetical. On 2026-09-24 three integration jobs failed on assertions about
13
+ * validators and served SPAs, and the cause in all three was an engine download refused twenty
14
+ * lines earlier and warned about. The same fallback made a local from-zero run look like a clean
15
+ * pass while the host engine path was never exercised at all.
16
+ */
17
+
18
+ /** True when the caller has asked for degraded paths to fail. */
19
+ export function strict(): boolean {
20
+ return process.env["SUPATYPE_STRICT"] === "1"
21
+ }
22
+
23
+ /**
24
+ * Report a path that worked less well than intended.
25
+ *
26
+ * Throws under `SUPATYPE_STRICT=1`, so a CI job cannot pass over it. Warns otherwise, because
27
+ * interactively a working stack and a note beats an abort.
28
+ *
29
+ * `what` names the capability that degraded, not the error: the reader needs to know which feature
30
+ * they are now without. `detail` carries the cause.
31
+ */
32
+ export function degraded(what: string, detail: string): void {
33
+ const message = `${what}: ${detail}`
34
+ if (strict()) {
35
+ throw new Error(
36
+ `${message}\nSUPATYPE_STRICT=1 is set, so a degraded path fails rather than warns.`,
37
+ )
38
+ }
39
+ console.warn(`[supatype] ${message}`)
40
+ }
@@ -2,8 +2,18 @@ import { existsSync } from "node:fs"
2
2
  import { join, resolve } from "node:path"
3
3
  import { ProcessManager } from "./process-manager.js"
4
4
 
5
- /** Vite dev server port when `overrides.studio` is set. */
6
- export const STUDIO_DEV_PORT = 3002
5
+ /**
6
+ * Vite dev server port when `overrides.studio` is set.
7
+ *
8
+ * Overridable because it is bound with `--strictPort`, so anything else already on 3002 does not
9
+ * make Vite pick another port, it makes Studio fail to start and be restarted every thirty seconds
10
+ * for the life of the session. 3002 is an ordinary port for a local app to be sitting on, and the
11
+ * only signal is a Vite stack trace in among the compose output.
12
+ *
13
+ * Only the `overrides.studio` path is affected, so this is a contributor's papercut rather than a
14
+ * user's: an ordinary `supatype dev` serves Studio from its container.
15
+ */
16
+ export const STUDIO_DEV_PORT = Number(process.env["SUPATYPE_STUDIO_DEV_PORT"]) || 3002
7
17
 
8
18
  export interface StudioDevServerOptions {
9
19
  cwd: string