@supatype/cli 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +168 -158
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +77 -0
- package/dist/api-config-cache.d.ts +20 -0
- package/dist/api-config-cache.d.ts.map +1 -1
- package/dist/api-config-cache.js +27 -0
- package/dist/api-config-cache.js.map +1 -1
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +126 -26
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/client-generator.d.ts +3 -0
- package/dist/client-generator.d.ts.map +1 -0
- package/dist/client-generator.js +108 -0
- package/dist/client-generator.js.map +1 -0
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +2 -2
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +3 -3
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +36 -29
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +14 -13
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/seed.d.ts +27 -1
- package/dist/commands/seed.d.ts.map +1 -1
- package/dist/commands/seed.js +206 -33
- package/dist/commands/seed.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +19 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/db-port.d.ts +36 -0
- package/dist/db-port.d.ts.map +1 -0
- package/dist/db-port.js +90 -0
- package/dist/db-port.js.map +1 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +108 -13
- package/dist/dev-compose.js.map +1 -1
- package/dist/engine-client.d.ts +1 -0
- package/dist/engine-client.d.ts.map +1 -1
- package/dist/engine-client.js +47 -2
- package/dist/engine-client.js.map +1 -1
- package/dist/engine-floor.d.ts +27 -4
- package/dist/engine-floor.d.ts.map +1 -1
- package/dist/engine-floor.js +90 -9
- package/dist/engine-floor.js.map +1 -1
- package/dist/functions-context-refresh.d.ts +4 -0
- package/dist/functions-context-refresh.d.ts.map +1 -0
- package/dist/functions-context-refresh.js +26 -0
- package/dist/functions-context-refresh.js.map +1 -0
- package/dist/functions-deno-types.d.ts +9 -0
- package/dist/functions-deno-types.d.ts.map +1 -1
- package/dist/functions-deno-types.js +21 -1
- package/dist/functions-deno-types.js.map +1 -1
- package/dist/gitignore.d.ts +3 -1
- package/dist/gitignore.d.ts.map +1 -1
- package/dist/gitignore.js +35 -6
- package/dist/gitignore.js.map +1 -1
- package/dist/hooks-generator.d.ts.map +1 -1
- package/dist/hooks-generator.js +18 -3
- package/dist/hooks-generator.js.map +1 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +17 -8
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/seed-connection.d.ts +60 -0
- package/dist/seed-connection.d.ts.map +1 -0
- package/dist/seed-connection.js +90 -0
- package/dist/seed-connection.js.map +1 -0
- package/dist/seed-ir.d.ts +66 -0
- package/dist/seed-ir.d.ts.map +1 -0
- package/dist/seed-ir.js +265 -0
- package/dist/seed-ir.js.map +1 -0
- package/dist/seed-runner.d.ts +44 -0
- package/dist/seed-runner.d.ts.map +1 -0
- package/dist/seed-runner.js +83 -0
- package/dist/seed-runner.js.map +1 -0
- package/dist/seed.d.ts +345 -0
- package/dist/seed.d.ts.map +1 -1
- package/dist/seed.js +138 -0
- package/dist/seed.js.map +1 -1
- package/dist/self-host-compose.d.ts +10 -2
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +73 -6
- package/dist/self-host-compose.js.map +1 -1
- package/dist/strict.d.ts +29 -0
- package/dist/strict.d.ts.map +1 -0
- package/dist/strict.js +37 -0
- package/dist/strict.js.map +1 -0
- package/dist/studio-dev-server.d.ts +12 -2
- package/dist/studio-dev-server.d.ts.map +1 -1
- package/dist/studio-dev-server.js +12 -2
- package/dist/studio-dev-server.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +81 -2
- package/dist/type-extractor.js.map +1 -1
- package/dist/type-generation.d.ts +43 -0
- package/dist/type-generation.d.ts.map +1 -1
- package/dist/type-generation.js +93 -2
- package/dist/type-generation.js.map +1 -1
- package/package.json +2 -2
- package/src/api-config-cache.ts +30 -0
- package/src/augmentation-generator.ts +142 -26
- package/src/cli-version-embedded.ts +1 -1
- package/src/client-generator.ts +122 -0
- package/src/commands/functions.ts +5 -2
- package/src/commands/generate.ts +7 -3
- package/src/commands/init.ts +41 -31
- package/src/commands/push.ts +14 -15
- package/src/commands/seed.ts +294 -44
- package/src/commands/update.ts +20 -1
- package/src/db-port.ts +99 -0
- package/src/dev-compose.ts +116 -16
- package/src/engine-client.ts +52 -3
- package/src/engine-floor.ts +100 -9
- package/src/functions-context-refresh.ts +25 -0
- package/src/functions-deno-types.ts +24 -1
- package/src/gitignore.ts +38 -10
- package/src/hooks-generator.ts +21 -6
- package/src/schema-ast-v2.ts +17 -8
- package/src/seed-connection.ts +146 -0
- package/src/seed-ir.ts +311 -0
- package/src/seed-runner.ts +109 -0
- package/src/seed.ts +408 -0
- package/src/self-host-compose.ts +74 -6
- package/src/strict.ts +40 -0
- package/src/studio-dev-server.ts +12 -2
- package/src/type-extractor.ts +109 -2
- package/src/type-generation.ts +111 -2
- package/tests/access-composition.test.ts +123 -0
- package/tests/ast-derived-outputs.test.ts +92 -0
- package/tests/augmentation-generator.test.ts +273 -7
- package/tests/client-generator.test.ts +103 -0
- package/tests/db-port.test.ts +128 -0
- package/tests/engine-floor.test.ts +93 -0
- package/tests/external-database-compose.test.ts +8 -1
- package/tests/fixtures/seed_golden_ir.json +186 -0
- package/tests/functions-context-refresh.test.ts +86 -0
- package/tests/gitignore-secrets.test.ts +90 -0
- package/tests/runtime-contract.test.ts +146 -8
- package/tests/seed-connection.test.ts +158 -0
- package/tests/seed-ir.test.ts +436 -0
- package/tests/strict.test.ts +78 -0
- package/tests/type-extractor.test.ts +34 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/type-extractor.ts
CHANGED
|
@@ -1055,7 +1055,10 @@ function parseScalarType(
|
|
|
1055
1055
|
const assetOpts = parseAssetFieldOptions(typeNode.typeArguments?.[1], sourceFile)
|
|
1056
1056
|
return attachStorageFieldMeta(
|
|
1057
1057
|
scalar("file", {
|
|
1058
|
-
|
|
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
|
-
|
|
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)) {
|
package/src/type-generation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
})
|