@supatype/cli 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +168 -158
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +77 -0
  5. package/dist/api-config-cache.d.ts +20 -0
  6. package/dist/api-config-cache.d.ts.map +1 -1
  7. package/dist/api-config-cache.js +27 -0
  8. package/dist/api-config-cache.js.map +1 -1
  9. package/dist/augmentation-generator.d.ts.map +1 -1
  10. package/dist/augmentation-generator.js +126 -26
  11. package/dist/augmentation-generator.js.map +1 -1
  12. package/dist/cli-version-embedded.js +1 -1
  13. package/dist/client-generator.d.ts +3 -0
  14. package/dist/client-generator.d.ts.map +1 -0
  15. package/dist/client-generator.js +108 -0
  16. package/dist/client-generator.js.map +1 -0
  17. package/dist/commands/functions.d.ts.map +1 -1
  18. package/dist/commands/functions.js +2 -2
  19. package/dist/commands/functions.js.map +1 -1
  20. package/dist/commands/generate.d.ts.map +1 -1
  21. package/dist/commands/generate.js +3 -3
  22. package/dist/commands/generate.js.map +1 -1
  23. package/dist/commands/init.d.ts.map +1 -1
  24. package/dist/commands/init.js +36 -29
  25. package/dist/commands/init.js.map +1 -1
  26. package/dist/commands/push.d.ts.map +1 -1
  27. package/dist/commands/push.js +14 -13
  28. package/dist/commands/push.js.map +1 -1
  29. package/dist/commands/seed.d.ts +27 -1
  30. package/dist/commands/seed.d.ts.map +1 -1
  31. package/dist/commands/seed.js +206 -33
  32. package/dist/commands/seed.js.map +1 -1
  33. package/dist/commands/update.d.ts.map +1 -1
  34. package/dist/commands/update.js +19 -1
  35. package/dist/commands/update.js.map +1 -1
  36. package/dist/db-port.d.ts +36 -0
  37. package/dist/db-port.d.ts.map +1 -0
  38. package/dist/db-port.js +90 -0
  39. package/dist/db-port.js.map +1 -0
  40. package/dist/dev-compose.d.ts.map +1 -1
  41. package/dist/dev-compose.js +108 -13
  42. package/dist/dev-compose.js.map +1 -1
  43. package/dist/engine-client.d.ts +1 -0
  44. package/dist/engine-client.d.ts.map +1 -1
  45. package/dist/engine-client.js +47 -2
  46. package/dist/engine-client.js.map +1 -1
  47. package/dist/engine-floor.d.ts +27 -4
  48. package/dist/engine-floor.d.ts.map +1 -1
  49. package/dist/engine-floor.js +90 -9
  50. package/dist/engine-floor.js.map +1 -1
  51. package/dist/functions-context-refresh.d.ts +4 -0
  52. package/dist/functions-context-refresh.d.ts.map +1 -0
  53. package/dist/functions-context-refresh.js +26 -0
  54. package/dist/functions-context-refresh.js.map +1 -0
  55. package/dist/functions-deno-types.d.ts +9 -0
  56. package/dist/functions-deno-types.d.ts.map +1 -1
  57. package/dist/functions-deno-types.js +21 -1
  58. package/dist/functions-deno-types.js.map +1 -1
  59. package/dist/gitignore.d.ts +3 -1
  60. package/dist/gitignore.d.ts.map +1 -1
  61. package/dist/gitignore.js +35 -6
  62. package/dist/gitignore.js.map +1 -1
  63. package/dist/hooks-generator.d.ts.map +1 -1
  64. package/dist/hooks-generator.js +18 -3
  65. package/dist/hooks-generator.js.map +1 -1
  66. package/dist/schema-ast-v2.d.ts.map +1 -1
  67. package/dist/schema-ast-v2.js +17 -8
  68. package/dist/schema-ast-v2.js.map +1 -1
  69. package/dist/seed-connection.d.ts +60 -0
  70. package/dist/seed-connection.d.ts.map +1 -0
  71. package/dist/seed-connection.js +90 -0
  72. package/dist/seed-connection.js.map +1 -0
  73. package/dist/seed-ir.d.ts +66 -0
  74. package/dist/seed-ir.d.ts.map +1 -0
  75. package/dist/seed-ir.js +265 -0
  76. package/dist/seed-ir.js.map +1 -0
  77. package/dist/seed-runner.d.ts +44 -0
  78. package/dist/seed-runner.d.ts.map +1 -0
  79. package/dist/seed-runner.js +83 -0
  80. package/dist/seed-runner.js.map +1 -0
  81. package/dist/seed.d.ts +345 -0
  82. package/dist/seed.d.ts.map +1 -1
  83. package/dist/seed.js +138 -0
  84. package/dist/seed.js.map +1 -1
  85. package/dist/self-host-compose.d.ts +10 -2
  86. package/dist/self-host-compose.d.ts.map +1 -1
  87. package/dist/self-host-compose.js +73 -6
  88. package/dist/self-host-compose.js.map +1 -1
  89. package/dist/strict.d.ts +29 -0
  90. package/dist/strict.d.ts.map +1 -0
  91. package/dist/strict.js +37 -0
  92. package/dist/strict.js.map +1 -0
  93. package/dist/studio-dev-server.d.ts +12 -2
  94. package/dist/studio-dev-server.d.ts.map +1 -1
  95. package/dist/studio-dev-server.js +12 -2
  96. package/dist/studio-dev-server.js.map +1 -1
  97. package/dist/type-extractor.d.ts.map +1 -1
  98. package/dist/type-extractor.js +81 -2
  99. package/dist/type-extractor.js.map +1 -1
  100. package/dist/type-generation.d.ts +43 -0
  101. package/dist/type-generation.d.ts.map +1 -1
  102. package/dist/type-generation.js +93 -2
  103. package/dist/type-generation.js.map +1 -1
  104. package/package.json +2 -2
  105. package/src/api-config-cache.ts +30 -0
  106. package/src/augmentation-generator.ts +142 -26
  107. package/src/cli-version-embedded.ts +1 -1
  108. package/src/client-generator.ts +122 -0
  109. package/src/commands/functions.ts +5 -2
  110. package/src/commands/generate.ts +7 -3
  111. package/src/commands/init.ts +41 -31
  112. package/src/commands/push.ts +14 -15
  113. package/src/commands/seed.ts +294 -44
  114. package/src/commands/update.ts +20 -1
  115. package/src/db-port.ts +99 -0
  116. package/src/dev-compose.ts +116 -16
  117. package/src/engine-client.ts +52 -3
  118. package/src/engine-floor.ts +100 -9
  119. package/src/functions-context-refresh.ts +25 -0
  120. package/src/functions-deno-types.ts +24 -1
  121. package/src/gitignore.ts +38 -10
  122. package/src/hooks-generator.ts +21 -6
  123. package/src/schema-ast-v2.ts +17 -8
  124. package/src/seed-connection.ts +146 -0
  125. package/src/seed-ir.ts +311 -0
  126. package/src/seed-runner.ts +109 -0
  127. package/src/seed.ts +408 -0
  128. package/src/self-host-compose.ts +74 -6
  129. package/src/strict.ts +40 -0
  130. package/src/studio-dev-server.ts +12 -2
  131. package/src/type-extractor.ts +109 -2
  132. package/src/type-generation.ts +111 -2
  133. package/tests/access-composition.test.ts +123 -0
  134. package/tests/ast-derived-outputs.test.ts +92 -0
  135. package/tests/augmentation-generator.test.ts +273 -7
  136. package/tests/client-generator.test.ts +103 -0
  137. package/tests/db-port.test.ts +128 -0
  138. package/tests/engine-floor.test.ts +93 -0
  139. package/tests/external-database-compose.test.ts +8 -1
  140. package/tests/fixtures/seed_golden_ir.json +186 -0
  141. package/tests/functions-context-refresh.test.ts +86 -0
  142. package/tests/gitignore-secrets.test.ts +90 -0
  143. package/tests/runtime-contract.test.ts +146 -8
  144. package/tests/seed-connection.test.ts +158 -0
  145. package/tests/seed-ir.test.ts +436 -0
  146. package/tests/strict.test.ts +78 -0
  147. package/tests/type-extractor.test.ts +34 -0
  148. package/tsconfig.tsbuildinfo +1 -1
@@ -1055,7 +1055,10 @@ function parseScalarType(
1055
1055
  const assetOpts = parseAssetFieldOptions(typeNode.typeArguments?.[1], sourceFile)
1056
1056
  return attachStorageFieldMeta(
1057
1057
  scalar("file", {
1058
- db: { pgType: "TEXT" },
1058
+ // JSONB, not TEXT. The column holds { bucket, path }, this package's own
1059
+ // DEFAULT_DB_BY_KIND says JSONB, and the engine honours an explicit annotation, so
1060
+ // TEXT here was one code path from a text column with a JSON object written into it.
1061
+ db: { pgType: "JSONB" },
1059
1062
  kernel: { bucket, ...(assetOpts.localized && { localized: true }) },
1060
1063
  }),
1061
1064
  bucket,
@@ -1067,7 +1070,10 @@ function parseScalarType(
1067
1070
  const assetOpts = parseAssetFieldOptions(typeNode.typeArguments?.[1], sourceFile)
1068
1071
  return attachStorageFieldMeta(
1069
1072
  scalar("image", {
1070
- db: { pgType: "TEXT" },
1073
+ // JSONB, not TEXT. The column holds { bucket, path }, this package's own
1074
+ // DEFAULT_DB_BY_KIND says JSONB, and the engine honours an explicit annotation, so
1075
+ // TEXT here was one code path from a text column with a JSON object written into it.
1076
+ db: { pgType: "JSONB" },
1071
1077
  kernel: { bucket, ...(assetOpts.localized && { localized: true }) },
1072
1078
  }),
1073
1079
  bucket,
@@ -2898,6 +2904,104 @@ const TIME_UNITS = [
2898
2904
  /** Granularities for `StartOf<>`. */
2899
2905
  const TRUNC_UNITS = ["day", "week", "month", "year"] as const
2900
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
+
2901
3005
  /**
2902
3006
  * `Ago<30, "days">` / `FromNow<7, "days">`.
2903
3007
  *
@@ -3056,6 +3160,9 @@ function parseAccessOperand(
3056
3160
  case "Ago":
3057
3161
  case "FromNow":
3058
3162
  return parseDurationOperand(ref, operandArgs, sourceFile)
3163
+ case "After":
3164
+ case "Before":
3165
+ return parseOffsetOperand(ref, operandArgs, sourceFile)
3059
3166
  case "Claim": {
3060
3167
  const pathArg = typeNode.typeArguments?.[0]
3061
3168
  if (!pathArg || !ts.isLiteralTypeNode(pathArg) || !ts.isStringLiteral(pathArg.literal)) {
@@ -13,9 +13,26 @@
13
13
  */
14
14
 
15
15
  import { mkdirSync, writeFileSync } from "node:fs"
16
- import { dirname, resolve } from "node:path"
16
+ import { dirname, join, resolve, sep } from "node:path"
17
17
  import { generateClientAugmentation } from "./augmentation-generator.js"
18
18
  import { ensureEngine, engineRequest } from "./engine-client.js"
19
+ import { generateProjectClient } from "./client-generator.js"
20
+
21
+ /** The client a project imports, beside the generated types. */
22
+ const PROJECT_CLIENT_FILENAME = "client.ts"
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"
19
36
 
20
37
  export interface GenerateTypesRequest {
21
38
  cwd: string
@@ -26,6 +43,39 @@ export interface GenerateTypesRequest {
26
43
  clientPath?: string | undefined
27
44
  }
28
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
+
29
79
  /** Writes what was asked for and returns one message per file, for the caller to report. */
30
80
  export async function writeGeneratedTypes(req: GenerateTypesRequest): Promise<string[]> {
31
81
  const written: string[] = []
@@ -48,7 +98,45 @@ export async function writeGeneratedTypes(req: GenerateTypesRequest): Promise<st
48
98
  written.push(`Types written to ${req.typesPath}`)
49
99
  }
50
100
 
51
- // Generated locally from the AST, so it needs no engine round trip.
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
+
119
+ written.push(...writeAstDerivedOutputs(req))
120
+
121
+ return written
122
+ }
123
+
124
+ /**
125
+ * The generated files that come from the AST alone, with no engine round trip.
126
+ *
127
+ * Shared because three commands need them and only one was writing them. `push` and `generate`
128
+ * call {@link writeGeneratedTypes}; `dev` has its own type generation, for good reasons it should
129
+ * keep, and so never wrote these at all. The result was a project developed entirely through
130
+ * `supatype dev` that never received `index.d.ts`, so the module augmentation that makes
131
+ * `createClient` typed without a generic never happened, and never received the generated client,
132
+ * so every exact-value column fell back to asking the API.
133
+ *
134
+ * Kept separate from the types branch above rather than folding `dev` into it: that branch slices
135
+ * the engine's output from a marker and treats a failure as fatal, and `dev` must do neither.
136
+ */
137
+ export function writeAstDerivedOutputs(req: GenerateTypesRequest): string[] {
138
+ const written: string[] = []
139
+
52
140
  if (req.clientPath !== undefined && req.clientPath !== "") {
53
141
  const outPath = resolve(req.cwd, req.clientPath)
54
142
  mkdirSync(dirname(outPath), { recursive: true })
@@ -56,5 +144,26 @@ export async function writeGeneratedTypes(req: GenerateTypesRequest): Promise<st
56
144
  written.push(`Client augmentation written to ${req.clientPath}`)
57
145
  }
58
146
 
147
+ // The client the project imports. One file carrying the augmentation, the exact-value columns
148
+ // and a re-export of `createClient`, so an app writes a single import and tsconfig cannot lose
149
+ // any of it. See `client-generator.ts` for what each part replaced.
150
+ // Beside `output.client` when set, otherwise beside `output.types`. A project that configured
151
+ // only types still gets a usable client: this one file carries the augmentation and the exact
152
+ // columns as well as `createClient`, so without it the project has types and no way to use them
153
+ // that knows anything about its schema.
154
+ const clientDir = req.clientPath ?? req.typesPath
155
+ if (clientDir !== undefined && clientDir !== "") {
156
+ const relative = join(dirname(clientDir), PROJECT_CLIENT_FILENAME)
157
+ const outPath = resolve(req.cwd, relative)
158
+ mkdirSync(dirname(outPath), { recursive: true })
159
+ writeFileSync(outPath, generateProjectClient(req.ast), "utf8")
160
+ written.push(`Client written to ${toPosix(relative)}`)
161
+ }
162
+
59
163
  return written
60
164
  }
165
+
166
+ /** Reported with forward slashes, so the message reads the same on every platform. */
167
+ function toPosix(path: string): string {
168
+ return path.split(sep).join("/")
169
+ }
@@ -580,6 +580,129 @@ export type Post = Model<{
580
580
  })
581
581
  })
582
582
 
583
+
584
+ // `After` / `Before`: a point in time plus or minus a multi-unit duration. The
585
+ // composition `Ago` and `FromNow` could not express, because they carry one amount
586
+ // and one unit.
587
+ it("extracts a composed offset from a truncation base", () => {
588
+ const post = extract(
589
+ `
590
+ import type { Model, UUID, All, Gte, Lte, After, Before, Now, StartOf } from "@supatype/types"
591
+
592
+ export type Post = Model<{
593
+ id: UUID
594
+ }, {
595
+ access: {
596
+ read: All<[
597
+ Gte<"starts_at", After<StartOf<"day">, { days: 30, hours: 9 }>>,
598
+ Lte<"created_at", Before<Now, { weeks: 2 }>>
599
+ ]>
600
+ }
601
+ }>
602
+ `,
603
+ "offsets",
604
+ )
605
+
606
+ expect(access(post)["read"]).toEqual({
607
+ type: "all",
608
+ rules: [
609
+ {
610
+ type: "compare",
611
+ op: "gte",
612
+ left: { kind: "column", name: "starts_at" },
613
+ right: {
614
+ kind: "after",
615
+ base: { kind: "startOf", unit: "day" },
616
+ interval: { days: 30, hours: 9 },
617
+ },
618
+ },
619
+ {
620
+ type: "compare",
621
+ op: "lte",
622
+ left: { kind: "column", name: "created_at" },
623
+ right: {
624
+ kind: "before",
625
+ base: { kind: "now" },
626
+ interval: { weeks: 2 },
627
+ },
628
+ },
629
+ ],
630
+ })
631
+ })
632
+
633
+ // The base is what makes an offset meaningful. A column depends on the row, so it is
634
+ // not a point in time an offset can be measured from, and the engine refuses one too.
635
+ it("rejects a base that is not a point in time", () => {
636
+ expect(() =>
637
+ extract(
638
+ `
639
+ import type { Model, UUID, Gte, After, Ago } from "@supatype/types"
640
+ export type Post = Model<{ id: UUID }, {
641
+ access: { read: Gte<"a", After<Ago<1, "days">, { days: 1 }>> }
642
+ }>
643
+ `,
644
+ "offset-base",
645
+ ),
646
+ ).toThrow(/must be `Now` or `StartOf/)
647
+ })
648
+
649
+ it("rejects an unknown unit inside the interval", () => {
650
+ expect(() =>
651
+ extract(
652
+ `
653
+ import type { Model, UUID, Gte, After, Now } from "@supatype/types"
654
+ export type Post = Model<{ id: UUID }, {
655
+ access: { read: Gte<"a", After<Now, { dayz: 30 }>> }
656
+ }>
657
+ `,
658
+ "offset-unit",
659
+ ),
660
+ ).toThrow(/is not a unit of time/)
661
+ })
662
+
663
+ // Same rule as Ago/FromNow: direction lives in the operand name, not in a sign, so a
664
+ // negative amount is refused with the opposite operand named.
665
+ it("rejects a negative or fractional amount and names the opposite operand", () => {
666
+ const bad = (n: string) => `
667
+ import type { Model, UUID, Gte, After, Now } from "@supatype/types"
668
+ export type Post = Model<{ id: UUID }, {
669
+ access: { read: Gte<"a", After<Now, { days: ${n} }>> }
670
+ }>
671
+ `
672
+ expect(() => extract(bad("-5"), "offset-neg")).toThrow(/does not take a negative amount/)
673
+ expect(() => extract(bad("-5"), "offset-neg2")).toThrow(/`Before<>`/)
674
+ expect(() => extract(bad("0.5"), "offset-frac")).toThrow(/whole number/)
675
+ })
676
+
677
+ // An all-zero interval means the base unchanged, which is never what an author wrote.
678
+ it("rejects an interval that sums to nothing", () => {
679
+ expect(() =>
680
+ extract(
681
+ `
682
+ import type { Model, UUID, Gte, After, Now } from "@supatype/types"
683
+ export type Post = Model<{ id: UUID }, {
684
+ access: { read: Gte<"a", After<Now, { days: 0 }>> }
685
+ }>
686
+ `,
687
+ "offset-zero",
688
+ ),
689
+ ).toThrow(/empty interval/)
690
+ })
691
+
692
+ it("rejects a unit given twice", () => {
693
+ expect(() =>
694
+ extract(
695
+ `
696
+ import type { Model, UUID, Gte, After, Now } from "@supatype/types"
697
+ export type Post = Model<{ id: UUID }, {
698
+ access: { read: Gte<"a", After<Now, { days: 1, days: 2 }>> }
699
+ }>
700
+ `,
701
+ "offset-dup",
702
+ ),
703
+ ).toThrow(/twice/)
704
+ })
705
+
583
706
  // A permissive `"30 days"` string would be raw SQL by another name, so the amount
584
707
  // and unit are validated separately and the interval is reassembled from them.
585
708
  it("rejects a fractional or negative amount", () => {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The generated files that need no engine, and which `dev` never wrote.
3
+ *
4
+ * `push` and `generate` go through `writeGeneratedTypes`. `dev` has its own type generation, which
5
+ * it should keep: it slices the engine's output from a marker and treats a failure as a warning
6
+ * rather than killing the session. What it should not have had is its own idea of which files
7
+ * exist, and the result was that a project developed entirely through `supatype dev` never
8
+ * received the module augmentation that types `createClient` without a generic.
9
+ *
10
+ * Both files come from the AST alone, so they are shared and all three commands write them.
11
+ */
12
+ import { describe, expect, it, beforeEach, afterEach } from "vitest"
13
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
14
+ import { join } from "node:path"
15
+ import { tmpdir } from "node:os"
16
+ import { writeAstDerivedOutputs } from "../src/type-generation.js"
17
+
18
+ const ast = {
19
+ models: [
20
+ {
21
+ name: "JobPost",
22
+ annotations: { db: { tableName: "job_post" } },
23
+ fields: {
24
+ id: { kind: "uuid", required: true },
25
+ views: { kind: "bigInt", required: true },
26
+ },
27
+ },
28
+ ],
29
+ }
30
+
31
+ let cwd: string
32
+
33
+ beforeEach(() => {
34
+ cwd = mkdtempSync(join(tmpdir(), "supatype-outputs-"))
35
+ })
36
+
37
+ afterEach(() => {
38
+ rmSync(cwd, { recursive: true, force: true })
39
+ })
40
+
41
+ describe("writeAstDerivedOutputs", () => {
42
+ it("writes the augmentation and the project client together", () => {
43
+ const written = writeAstDerivedOutputs({
44
+ cwd,
45
+ ast,
46
+ typesPath: "supatype/generated/database.ts",
47
+ clientPath: "supatype/generated/index.d.ts",
48
+ })
49
+
50
+ expect(existsSync(join(cwd, "supatype/generated/index.d.ts"))).toBe(true)
51
+ expect(existsSync(join(cwd, "supatype/generated/client.ts"))).toBe(true)
52
+ expect(written).toHaveLength(2)
53
+ })
54
+
55
+ it("puts the client beside the generated types", () => {
56
+ // An app imports it by a relative path, so it has to land next to the rest of the generated
57
+ // output rather than at the project root.
58
+ writeAstDerivedOutputs({ cwd, ast, clientPath: "supatype/generated/index.d.ts" })
59
+ const body = readFileSync(join(cwd, "supatype/generated/client.ts"), "utf8")
60
+
61
+ expect(body).toContain('export * from "@supatype/client"')
62
+ expect(body).toContain('declare module "@supatype/client"')
63
+ expect(body).toContain('"views": "bigint"')
64
+ })
65
+
66
+ it("writes nothing when a project configured no output at all", () => {
67
+ // `push` must not start creating files in projects that never asked for any.
68
+ expect(writeAstDerivedOutputs({ cwd, ast })).toEqual([])
69
+ expect(existsSync(join(cwd, "supatype"))).toBe(false)
70
+ })
71
+
72
+ it("needs no engine, so it can run when type generation could not", () => {
73
+ // This is why it is separate: in `dev` the engine may be unavailable, and the augmentation and
74
+ // the client should still reach the project.
75
+ const written = writeAstDerivedOutputs({ cwd, ast, clientPath: "supatype/generated/index.d.ts" })
76
+ expect(written.some((line) => line.includes("Client written"))).toBe(true)
77
+ })
78
+ })
79
+
80
+ describe("a project that configured only types", () => {
81
+ it("still gets a client, beside the types", () => {
82
+ // Otherwise it has generated types and no way to use them that knows its schema: the
83
+ // augmentation and the exact-value columns both travel in this file.
84
+ const dir = mkdtempSync(join(tmpdir(), "supatype-types-only-"))
85
+ try {
86
+ writeAstDerivedOutputs({ cwd: dir, ast, typesPath: "supatype/generated/database.ts" })
87
+ expect(existsSync(join(dir, "supatype/generated/client.ts"))).toBe(true)
88
+ } finally {
89
+ rmSync(dir, { recursive: true, force: true })
90
+ }
91
+ })
92
+ })