@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +168 -158
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +77 -0
- 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 +126 -26
- 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/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +3 -3
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +36 -29
- 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/seed.d.ts +27 -1
- package/dist/commands/seed.d.ts.map +1 -1
- package/dist/commands/seed.js +206 -33
- package/dist/commands/seed.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/engine-client.d.ts +1 -0
- package/dist/engine-client.d.ts.map +1 -1
- package/dist/engine-client.js +47 -2
- package/dist/engine-client.js.map +1 -1
- package/dist/engine-floor.d.ts +27 -4
- package/dist/engine-floor.d.ts.map +1 -1
- package/dist/engine-floor.js +90 -9
- package/dist/engine-floor.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 +35 -6
- package/dist/gitignore.js.map +1 -1
- package/dist/hooks-generator.d.ts.map +1 -1
- package/dist/hooks-generator.js +18 -3
- package/dist/hooks-generator.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/seed-connection.d.ts +60 -0
- package/dist/seed-connection.d.ts.map +1 -0
- package/dist/seed-connection.js +90 -0
- package/dist/seed-connection.js.map +1 -0
- package/dist/seed-ir.d.ts +66 -0
- package/dist/seed-ir.d.ts.map +1 -0
- package/dist/seed-ir.js +265 -0
- package/dist/seed-ir.js.map +1 -0
- package/dist/seed-runner.d.ts +44 -0
- package/dist/seed-runner.d.ts.map +1 -0
- package/dist/seed-runner.js +83 -0
- package/dist/seed-runner.js.map +1 -0
- package/dist/seed.d.ts +345 -0
- package/dist/seed.d.ts.map +1 -1
- package/dist/seed.js +138 -0
- package/dist/seed.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 +73 -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 +81 -2
- package/dist/type-extractor.js.map +1 -1
- package/dist/type-generation.d.ts +43 -0
- package/dist/type-generation.d.ts.map +1 -1
- package/dist/type-generation.js +93 -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 +142 -26
- 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/generate.ts +7 -3
- package/src/commands/init.ts +41 -31
- package/src/commands/push.ts +14 -15
- package/src/commands/seed.ts +294 -44
- package/src/commands/update.ts +20 -1
- package/src/db-port.ts +99 -0
- package/src/dev-compose.ts +116 -16
- package/src/engine-client.ts +52 -3
- package/src/engine-floor.ts +100 -9
- package/src/functions-context-refresh.ts +25 -0
- package/src/functions-deno-types.ts +24 -1
- package/src/gitignore.ts +38 -10
- package/src/hooks-generator.ts +21 -6
- package/src/schema-ast-v2.ts +17 -8
- package/src/seed-connection.ts +146 -0
- package/src/seed-ir.ts +311 -0
- package/src/seed-runner.ts +109 -0
- package/src/seed.ts +408 -0
- package/src/self-host-compose.ts +74 -6
- package/src/strict.ts +40 -0
- package/src/studio-dev-server.ts +12 -2
- package/src/type-extractor.ts +109 -2
- package/src/type-generation.ts +111 -2
- package/tests/access-composition.test.ts +123 -0
- package/tests/ast-derived-outputs.test.ts +92 -0
- package/tests/augmentation-generator.test.ts +273 -7
- package/tests/client-generator.test.ts +103 -0
- package/tests/db-port.test.ts +128 -0
- package/tests/engine-floor.test.ts +93 -0
- package/tests/external-database-compose.test.ts +8 -1
- package/tests/fixtures/seed_golden_ir.json +186 -0
- package/tests/functions-context-refresh.test.ts +86 -0
- package/tests/gitignore-secrets.test.ts +90 -0
- package/tests/runtime-contract.test.ts +146 -8
- package/tests/seed-connection.test.ts +158 -0
- package/tests/seed-ir.test.ts +436 -0
- package/tests/strict.test.ts +78 -0
- package/tests/type-extractor.test.ts +34 -0
- 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
|
+
}
|
package/src/self-host-compose.ts
CHANGED
|
@@ -14,9 +14,10 @@ import {
|
|
|
14
14
|
usesExternalDatabase,
|
|
15
15
|
type SupatypeProjectConfig,
|
|
16
16
|
} from "./project-config.js"
|
|
17
|
-
import {
|
|
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
|
-
/**
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
+
}
|
package/src/studio-dev-server.ts
CHANGED
|
@@ -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
|
-
/**
|
|
6
|
-
|
|
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
|