@supatype/cli 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +163 -161
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +77 -0
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +70 -15
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/cli-version-embedded.js +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 +29 -19
- package/dist/commands/init.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/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/gitignore.d.ts.map +1 -1
- package/dist/gitignore.js +7 -0
- 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/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.map +1 -1
- package/dist/self-host-compose.js +14 -8
- package/dist/self-host-compose.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +73 -0
- package/dist/type-extractor.js.map +1 -1
- package/dist/type-generation.d.ts +29 -0
- package/dist/type-generation.d.ts.map +1 -1
- package/dist/type-generation.js +52 -0
- package/dist/type-generation.js.map +1 -1
- package/package.json +2 -2
- package/src/augmentation-generator.ts +73 -14
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/generate.ts +7 -3
- package/src/commands/init.ts +31 -21
- package/src/commands/seed.ts +294 -44
- package/src/engine-client.ts +52 -3
- package/src/engine-floor.ts +100 -9
- package/src/gitignore.ts +7 -0
- package/src/hooks-generator.ts +21 -6
- 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 +14 -8
- package/src/type-extractor.ts +101 -0
- package/src/type-generation.ts +64 -0
- package/tests/access-composition.test.ts +123 -0
- package/tests/augmentation-generator.test.ts +149 -9
- package/tests/engine-floor.test.ts +93 -0
- package/tests/fixtures/seed_golden_ir.json +186 -0
- package/tests/runtime-contract.test.ts +31 -6
- package/tests/seed-connection.test.ts +158 -0
- package/tests/seed-ir.test.ts +436 -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,11 +14,11 @@ 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
20
|
import { STUDIO_DEV_PORT } from "./studio-dev-server.js"
|
|
21
|
-
import {
|
|
21
|
+
import { readEnvFile } from "./env-file.js"
|
|
22
22
|
import { fieldMaskingTierFromProject, type FieldMaskingTier } from "./field-masking-tier.js"
|
|
23
23
|
import { projectHasVersionedModels } from "./model-versioning.js"
|
|
24
24
|
|
|
@@ -404,12 +404,18 @@ ${studioService}
|
|
|
404
404
|
: ` - server
|
|
405
405
|
- studio
|
|
406
406
|
- control-plane`
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
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
|
|
413
419
|
const dbPorts = publishDbToHost
|
|
414
420
|
? devLocal
|
|
415
421
|
? ` ports:
|
package/src/type-extractor.ts
CHANGED
|
@@ -2904,6 +2904,104 @@ const TIME_UNITS = [
|
|
|
2904
2904
|
/** Granularities for `StartOf<>`. */
|
|
2905
2905
|
const TRUNC_UNITS = ["day", "week", "month", "year"] as const
|
|
2906
2906
|
|
|
2907
|
+
/**
|
|
2908
|
+
* `After<Base, { days: 30, hours: 9 }>` / `Before<...>`: a point in time plus or minus a
|
|
2909
|
+
* multi-unit duration.
|
|
2910
|
+
*
|
|
2911
|
+
* The interval is re-assembled from parsed integers and matched keywords, exactly as
|
|
2912
|
+
* `parseDurationOperand` does, so no author-supplied text reaches Postgres. A permissive
|
|
2913
|
+
* `"30 days 9 hours"` string would be raw SQL by another name.
|
|
2914
|
+
*
|
|
2915
|
+
* Direction is in the operand name rather than the sign of a component, which is the rule
|
|
2916
|
+
* `Ago`/`FromNow` already follow: a negative amount is refused with a message naming the
|
|
2917
|
+
* opposite operand, because relying on a double negative reads wrongly.
|
|
2918
|
+
*/
|
|
2919
|
+
function parseOffsetOperand(
|
|
2920
|
+
ref: "After" | "Before",
|
|
2921
|
+
args: readonly ts.TypeNode[],
|
|
2922
|
+
sourceFile: ts.SourceFile,
|
|
2923
|
+
): Record<string, unknown> {
|
|
2924
|
+
if (args.length !== 2) {
|
|
2925
|
+
throw new Error(
|
|
2926
|
+
`\`${ref}<>\` takes a base and an interval, as in ` +
|
|
2927
|
+
`\`${ref}<StartOf<"day">, { days: 30, hours: 9 }>\`.`,
|
|
2928
|
+
)
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
const base = parseAccessOperand(args[0]!, sourceFile)
|
|
2932
|
+
const baseKind = typeof base["kind"] === "string" ? base["kind"] : ""
|
|
2933
|
+
if (baseKind !== "now" && baseKind !== "startOf") {
|
|
2934
|
+
throw new Error(
|
|
2935
|
+
`\`${ref}<>\` measures from a point in time, so its base must be \`Now\` or ` +
|
|
2936
|
+
`\`StartOf<unit>\`. A column or a claim depends on the row or the caller and ` +
|
|
2937
|
+
`cannot be offset.`,
|
|
2938
|
+
)
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
const intervalNode = args[1]!
|
|
2942
|
+
if (!ts.isTypeLiteralNode(intervalNode)) {
|
|
2943
|
+
throw new Error(
|
|
2944
|
+
`\`${ref}<>\` takes the interval as an object, as in \`{ days: 30, hours: 9 }\`.`,
|
|
2945
|
+
)
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
const opposite = ref === "After" ? "Before" : "After"
|
|
2949
|
+
const interval: Record<string, number> = {}
|
|
2950
|
+
|
|
2951
|
+
for (const member of intervalNode.members) {
|
|
2952
|
+
if (!ts.isPropertySignature(member) || !member.name || !member.type) {
|
|
2953
|
+
throw new Error(`\`${ref}<>\` interval members must be \`unit: amount\` pairs.`)
|
|
2954
|
+
}
|
|
2955
|
+
const unit = member.name.getText(sourceFile).replace(/["']/g, "")
|
|
2956
|
+
if (!TIME_UNITS.includes(unit as (typeof TIME_UNITS)[number])) {
|
|
2957
|
+
throw new Error(
|
|
2958
|
+
`\`${unit}\` is not a unit of time. Use one of: ${TIME_UNITS.join(", ")} (plural).`,
|
|
2959
|
+
)
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
const amountNode = member.type
|
|
2963
|
+
// A negative literal is a prefix-unary expression, not a numeric literal, so it has
|
|
2964
|
+
// to be recognised separately or it is refused for the wrong reason and the message
|
|
2965
|
+
// misses the real advice.
|
|
2966
|
+
if (
|
|
2967
|
+
ts.isLiteralTypeNode(amountNode) &&
|
|
2968
|
+
ts.isPrefixUnaryExpression(amountNode.literal) &&
|
|
2969
|
+
amountNode.literal.operator === ts.SyntaxKind.MinusToken
|
|
2970
|
+
) {
|
|
2971
|
+
throw new Error(
|
|
2972
|
+
`\`${ref}<>\` does not take a negative amount. For the other direction use ` +
|
|
2973
|
+
`\`${opposite}<>\`, which reads correctly instead of relying on a double negative.`,
|
|
2974
|
+
)
|
|
2975
|
+
}
|
|
2976
|
+
if (!ts.isLiteralTypeNode(amountNode) || !ts.isNumericLiteral(amountNode.literal)) {
|
|
2977
|
+
throw new Error(`\`${unit}\` needs a number literal amount, as in \`{ ${unit}: 30 }\`.`)
|
|
2978
|
+
}
|
|
2979
|
+
const amount = Number(amountNode.literal.text)
|
|
2980
|
+
if (!Number.isInteger(amount)) {
|
|
2981
|
+
throw new Error(
|
|
2982
|
+
`\`{ ${unit}: ${amount} }\` must be a whole number of units: Postgres intervals ` +
|
|
2983
|
+
`take integers, so a fraction would be silently truncated or rejected.`,
|
|
2984
|
+
)
|
|
2985
|
+
}
|
|
2986
|
+
if (unit in interval) {
|
|
2987
|
+
throw new Error(`\`${ref}<>\` names \`${unit}\` twice; give each unit once.`)
|
|
2988
|
+
}
|
|
2989
|
+
interval[unit] = amount
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
// An all-zero interval means the base unchanged, which is never what an author wrote.
|
|
2993
|
+
// The engine refuses it too, but saying so here names the operand at the point it was
|
|
2994
|
+
// written rather than at push.
|
|
2995
|
+
if (Object.values(interval).every((amount) => amount === 0)) {
|
|
2996
|
+
throw new Error(
|
|
2997
|
+
`\`${ref}<>\` was given an empty interval, which means the base unchanged. ` +
|
|
2998
|
+
`Give at least one non-zero unit, as in \`{ days: 30 }\`.`,
|
|
2999
|
+
)
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
return { kind: ref === "After" ? "after" : "before", base, interval }
|
|
3003
|
+
}
|
|
3004
|
+
|
|
2907
3005
|
/**
|
|
2908
3006
|
* `Ago<30, "days">` / `FromNow<7, "days">`.
|
|
2909
3007
|
*
|
|
@@ -3062,6 +3160,9 @@ function parseAccessOperand(
|
|
|
3062
3160
|
case "Ago":
|
|
3063
3161
|
case "FromNow":
|
|
3064
3162
|
return parseDurationOperand(ref, operandArgs, sourceFile)
|
|
3163
|
+
case "After":
|
|
3164
|
+
case "Before":
|
|
3165
|
+
return parseOffsetOperand(ref, operandArgs, sourceFile)
|
|
3065
3166
|
case "Claim": {
|
|
3066
3167
|
const pathArg = typeNode.typeArguments?.[0]
|
|
3067
3168
|
if (!pathArg || !ts.isLiteralTypeNode(pathArg) || !ts.isStringLiteral(pathArg.literal)) {
|
package/src/type-generation.ts
CHANGED
|
@@ -21,6 +21,19 @@ import { generateProjectClient } from "./client-generator.js"
|
|
|
21
21
|
/** The client a project imports, beside the generated types. */
|
|
22
22
|
const PROJECT_CLIENT_FILENAME = "client.ts"
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Where generated output goes when a project has not said otherwise.
|
|
26
|
+
*
|
|
27
|
+
* Exported because two commands need the same answer: `generate` writes these files and
|
|
28
|
+
* `seed` reads one of them, and when they disagreed the builder was written to a path the
|
|
29
|
+
* seed run then reported as missing.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_TYPES_PATH = "types/database.ts"
|
|
32
|
+
export const DEFAULT_CLIENT_PATH = "supatype/generated/index.d.ts"
|
|
33
|
+
|
|
34
|
+
/** The seed builder, beside the client, because both are generated from the same schema. */
|
|
35
|
+
const SEED_BUILDER_FILENAME = "seed.ts"
|
|
36
|
+
|
|
24
37
|
export interface GenerateTypesRequest {
|
|
25
38
|
cwd: string
|
|
26
39
|
ast: unknown
|
|
@@ -30,6 +43,39 @@ export interface GenerateTypesRequest {
|
|
|
30
43
|
clientPath?: string | undefined
|
|
31
44
|
}
|
|
32
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Where the generated seed builder lives for this project.
|
|
48
|
+
*
|
|
49
|
+
* One rule, exported, because two things need the answer and a seed run that looked
|
|
50
|
+
* somewhere else would report a missing builder for a file that had just been written.
|
|
51
|
+
*/
|
|
52
|
+
export function seedBuilderPath(req: {
|
|
53
|
+
typesPath?: string | undefined
|
|
54
|
+
clientPath?: string | undefined
|
|
55
|
+
}): string | undefined {
|
|
56
|
+
const directory = req.clientPath ?? req.typesPath
|
|
57
|
+
if (directory === undefined || directory === "") return undefined
|
|
58
|
+
return join(dirname(directory), SEED_BUILDER_FILENAME)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The same answer for a project that has configured nothing.
|
|
63
|
+
*
|
|
64
|
+
* `generate` writes with the defaults applied, so a reader must resolve them the same way or
|
|
65
|
+
* it looks for a file beside a path the project never set.
|
|
66
|
+
*/
|
|
67
|
+
export function seedBuilderPathWithDefaults(output?: {
|
|
68
|
+
types?: string | undefined
|
|
69
|
+
client?: string | undefined
|
|
70
|
+
}): string {
|
|
71
|
+
return (
|
|
72
|
+
seedBuilderPath({
|
|
73
|
+
typesPath: output?.types ?? DEFAULT_TYPES_PATH,
|
|
74
|
+
clientPath: output?.client ?? DEFAULT_CLIENT_PATH,
|
|
75
|
+
}) ?? join(dirname(DEFAULT_CLIENT_PATH), SEED_BUILDER_FILENAME)
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
/** Writes what was asked for and returns one message per file, for the caller to report. */
|
|
34
80
|
export async function writeGeneratedTypes(req: GenerateTypesRequest): Promise<string[]> {
|
|
35
81
|
const written: string[] = []
|
|
@@ -52,6 +98,24 @@ export async function writeGeneratedTypes(req: GenerateTypesRequest): Promise<st
|
|
|
52
98
|
written.push(`Types written to ${req.typesPath}`)
|
|
53
99
|
}
|
|
54
100
|
|
|
101
|
+
const seedPath = seedBuilderPath(req)
|
|
102
|
+
if (seedPath !== undefined) {
|
|
103
|
+
await ensureEngine()
|
|
104
|
+
const result = await engineRequest<{ code?: string; message?: string }>("/generate", {
|
|
105
|
+
ast: req.ast,
|
|
106
|
+
lang: "typescript",
|
|
107
|
+
artifact: "seed",
|
|
108
|
+
})
|
|
109
|
+
const code = result.code ?? result.message
|
|
110
|
+
if (code === undefined) {
|
|
111
|
+
throw new Error("Engine returned no output for the seed builder.")
|
|
112
|
+
}
|
|
113
|
+
const outPath = resolve(req.cwd, seedPath)
|
|
114
|
+
mkdirSync(dirname(outPath), { recursive: true })
|
|
115
|
+
writeFileSync(outPath, code, "utf8")
|
|
116
|
+
written.push(`Seed builder written to ${toPosix(seedPath)}`)
|
|
117
|
+
}
|
|
118
|
+
|
|
55
119
|
written.push(...writeAstDerivedOutputs(req))
|
|
56
120
|
|
|
57
121
|
return written
|