@supatype/cli 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +163 -161
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +77 -0
  5. package/dist/augmentation-generator.d.ts.map +1 -1
  6. package/dist/augmentation-generator.js +70 -15
  7. package/dist/augmentation-generator.js.map +1 -1
  8. package/dist/cli-version-embedded.js +1 -1
  9. package/dist/commands/generate.d.ts.map +1 -1
  10. package/dist/commands/generate.js +3 -3
  11. package/dist/commands/generate.js.map +1 -1
  12. package/dist/commands/init.d.ts.map +1 -1
  13. package/dist/commands/init.js +29 -19
  14. package/dist/commands/init.js.map +1 -1
  15. package/dist/commands/seed.d.ts +27 -1
  16. package/dist/commands/seed.d.ts.map +1 -1
  17. package/dist/commands/seed.js +206 -33
  18. package/dist/commands/seed.js.map +1 -1
  19. package/dist/engine-client.d.ts +1 -0
  20. package/dist/engine-client.d.ts.map +1 -1
  21. package/dist/engine-client.js +47 -2
  22. package/dist/engine-client.js.map +1 -1
  23. package/dist/engine-floor.d.ts +27 -4
  24. package/dist/engine-floor.d.ts.map +1 -1
  25. package/dist/engine-floor.js +90 -9
  26. package/dist/engine-floor.js.map +1 -1
  27. package/dist/gitignore.d.ts.map +1 -1
  28. package/dist/gitignore.js +7 -0
  29. package/dist/gitignore.js.map +1 -1
  30. package/dist/hooks-generator.d.ts.map +1 -1
  31. package/dist/hooks-generator.js +18 -3
  32. package/dist/hooks-generator.js.map +1 -1
  33. package/dist/seed-connection.d.ts +60 -0
  34. package/dist/seed-connection.d.ts.map +1 -0
  35. package/dist/seed-connection.js +90 -0
  36. package/dist/seed-connection.js.map +1 -0
  37. package/dist/seed-ir.d.ts +66 -0
  38. package/dist/seed-ir.d.ts.map +1 -0
  39. package/dist/seed-ir.js +265 -0
  40. package/dist/seed-ir.js.map +1 -0
  41. package/dist/seed-runner.d.ts +44 -0
  42. package/dist/seed-runner.d.ts.map +1 -0
  43. package/dist/seed-runner.js +83 -0
  44. package/dist/seed-runner.js.map +1 -0
  45. package/dist/seed.d.ts +345 -0
  46. package/dist/seed.d.ts.map +1 -1
  47. package/dist/seed.js +138 -0
  48. package/dist/seed.js.map +1 -1
  49. package/dist/self-host-compose.d.ts.map +1 -1
  50. package/dist/self-host-compose.js +14 -8
  51. package/dist/self-host-compose.js.map +1 -1
  52. package/dist/type-extractor.d.ts.map +1 -1
  53. package/dist/type-extractor.js +73 -0
  54. package/dist/type-extractor.js.map +1 -1
  55. package/dist/type-generation.d.ts +29 -0
  56. package/dist/type-generation.d.ts.map +1 -1
  57. package/dist/type-generation.js +52 -0
  58. package/dist/type-generation.js.map +1 -1
  59. package/package.json +2 -2
  60. package/src/augmentation-generator.ts +73 -14
  61. package/src/cli-version-embedded.ts +1 -1
  62. package/src/commands/generate.ts +7 -3
  63. package/src/commands/init.ts +31 -21
  64. package/src/commands/seed.ts +294 -44
  65. package/src/engine-client.ts +52 -3
  66. package/src/engine-floor.ts +100 -9
  67. package/src/gitignore.ts +7 -0
  68. package/src/hooks-generator.ts +21 -6
  69. package/src/seed-connection.ts +146 -0
  70. package/src/seed-ir.ts +311 -0
  71. package/src/seed-runner.ts +109 -0
  72. package/src/seed.ts +408 -0
  73. package/src/self-host-compose.ts +14 -8
  74. package/src/type-extractor.ts +101 -0
  75. package/src/type-generation.ts +64 -0
  76. package/tests/access-composition.test.ts +123 -0
  77. package/tests/augmentation-generator.test.ts +149 -9
  78. package/tests/engine-floor.test.ts +93 -0
  79. package/tests/fixtures/seed_golden_ir.json +186 -0
  80. package/tests/runtime-contract.test.ts +31 -6
  81. package/tests/seed-connection.test.ts +158 -0
  82. package/tests/seed-ir.test.ts +436 -0
  83. package/tsconfig.tsbuildinfo +1 -1
package/src/seed.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,11 +14,11 @@ 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
20
  import { STUDIO_DEV_PORT } from "./studio-dev-server.js"
21
- import { hasEnvValue, readEnvFile } from "./env-file.js"
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
- // In dev the database is published only when something on the host needs to reach it, which is
408
- // normally a host engine build. A project that names a port is asking for one too: without this,
409
- // `SUPATYPE_DEV_DB_PORT` was honoured for the number and ignored for whether the port existed,
410
- // so a seed connecting over TCP got ECONNREFUSED and nothing said why.
411
- const dbPortRequested = hasEnvValue(cwd, "SUPATYPE_DEV_DB_PORT")
412
- const publishDbToHost = !devLocal || hasEngineOverride(config) || dbPortRequested
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:
@@ -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)) {
@@ -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