@shivaedev/build 0.0.0 → 0.1.0

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 (209) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +215 -1
  4. package/dist/BuildFailure.d.ts +9 -0
  5. package/dist/BuildFailure.d.ts.map +1 -0
  6. package/dist/BuildFailure.js +4 -0
  7. package/dist/BuildFailure.js.map +1 -0
  8. package/dist/adapters/appTestConfig.d.ts +4 -0
  9. package/dist/adapters/appTestConfig.d.ts.map +1 -0
  10. package/dist/adapters/appTestConfig.js +17 -0
  11. package/dist/adapters/appTestConfig.js.map +1 -0
  12. package/dist/adapters/appViteConfig.d.ts +26 -0
  13. package/dist/adapters/appViteConfig.d.ts.map +1 -0
  14. package/dist/adapters/appViteConfig.js +18 -0
  15. package/dist/adapters/appViteConfig.js.map +1 -0
  16. package/dist/adapters/bundleCheck.d.ts +3 -0
  17. package/dist/adapters/bundleCheck.d.ts.map +1 -0
  18. package/dist/adapters/bundleCheck.js +53 -0
  19. package/dist/adapters/bundleCheck.js.map +1 -0
  20. package/dist/adapters/copiedCode.d.ts +9 -0
  21. package/dist/adapters/copiedCode.d.ts.map +1 -0
  22. package/dist/adapters/copiedCode.js +84 -0
  23. package/dist/adapters/copiedCode.js.map +1 -0
  24. package/dist/adapters/failuresOf.d.ts +5 -0
  25. package/dist/adapters/failuresOf.d.ts.map +1 -0
  26. package/dist/adapters/failuresOf.js +29 -0
  27. package/dist/adapters/failuresOf.js.map +1 -0
  28. package/dist/adapters/isReadonlyProperty.d.ts +10 -0
  29. package/dist/adapters/isReadonlyProperty.d.ts.map +1 -0
  30. package/dist/adapters/isReadonlyProperty.js +7 -0
  31. package/dist/adapters/isReadonlyProperty.js.map +1 -0
  32. package/dist/adapters/moduleCalls.d.ts +10 -0
  33. package/dist/adapters/moduleCalls.d.ts.map +1 -0
  34. package/dist/adapters/moduleCalls.js +47 -0
  35. package/dist/adapters/moduleCalls.js.map +1 -0
  36. package/dist/adapters/moduleFacts.d.ts +4 -0
  37. package/dist/adapters/moduleFacts.d.ts.map +1 -0
  38. package/dist/adapters/moduleFacts.js +63 -0
  39. package/dist/adapters/moduleFacts.js.map +1 -0
  40. package/dist/adapters/objectForm.d.ts +8 -0
  41. package/dist/adapters/objectForm.d.ts.map +1 -0
  42. package/dist/adapters/objectForm.js +66 -0
  43. package/dist/adapters/objectForm.js.map +1 -0
  44. package/dist/adapters/operationFacts.d.ts +10 -0
  45. package/dist/adapters/operationFacts.d.ts.map +1 -0
  46. package/dist/adapters/operationFacts.js +72 -0
  47. package/dist/adapters/operationFacts.js.map +1 -0
  48. package/dist/adapters/runNode.d.ts +6 -0
  49. package/dist/adapters/runNode.d.ts.map +1 -0
  50. package/dist/adapters/runNode.js +14 -0
  51. package/dist/adapters/runNode.js.map +1 -0
  52. package/dist/adapters/typeReading.d.ts +15 -0
  53. package/dist/adapters/typeReading.d.ts.map +1 -0
  54. package/dist/adapters/typeReading.js +46 -0
  55. package/dist/adapters/typeReading.js.map +1 -0
  56. package/dist/adapters/typeScript.d.ts +15 -0
  57. package/dist/adapters/typeScript.d.ts.map +1 -0
  58. package/dist/adapters/typeScript.js +81 -0
  59. package/dist/adapters/typeScript.js.map +1 -0
  60. package/dist/adapters/unionForm.d.ts +7 -0
  61. package/dist/adapters/unionForm.d.ts.map +1 -0
  62. package/dist/adapters/unionForm.js +78 -0
  63. package/dist/adapters/unionForm.js.map +1 -0
  64. package/dist/adapters/vitePlugin.d.ts +7 -0
  65. package/dist/adapters/vitePlugin.d.ts.map +1 -0
  66. package/dist/adapters/vitePlugin.js +88 -0
  67. package/dist/adapters/vitePlugin.js.map +1 -0
  68. package/dist/adapters/vitePort.d.ts +2 -0
  69. package/dist/adapters/vitePort.d.ts.map +1 -0
  70. package/dist/adapters/vitePort.js +5 -0
  71. package/dist/adapters/vitePort.js.map +1 -0
  72. package/dist/adapters/vitest/setup.d.ts +3 -0
  73. package/dist/adapters/vitest/setup.d.ts.map +1 -0
  74. package/dist/adapters/vitest/setup.js +11 -0
  75. package/dist/adapters/vitest/setup.js.map +1 -0
  76. package/dist/adapters/wireForm.d.ts +5 -0
  77. package/dist/adapters/wireForm.d.ts.map +1 -0
  78. package/dist/adapters/wireForm.js +91 -0
  79. package/dist/adapters/wireForm.js.map +1 -0
  80. package/dist/byText.d.ts +2 -0
  81. package/dist/byText.d.ts.map +1 -0
  82. package/dist/byText.js +8 -0
  83. package/dist/byText.js.map +1 -0
  84. package/dist/cache.d.ts +4 -0
  85. package/dist/cache.d.ts.map +1 -0
  86. package/dist/cache.js +19 -0
  87. package/dist/cache.js.map +1 -0
  88. package/dist/cli.d.ts +3 -0
  89. package/dist/cli.d.ts.map +1 -0
  90. package/dist/cli.js +11 -0
  91. package/dist/cli.js.map +1 -0
  92. package/dist/code.d.ts +11 -0
  93. package/dist/code.d.ts.map +1 -0
  94. package/dist/code.js +11 -0
  95. package/dist/code.js.map +1 -0
  96. package/dist/command.d.ts +6 -0
  97. package/dist/command.d.ts.map +1 -0
  98. package/dist/command.js +28 -0
  99. package/dist/command.js.map +1 -0
  100. package/dist/emit.d.ts +23 -0
  101. package/dist/emit.d.ts.map +1 -0
  102. package/dist/emit.js +69 -0
  103. package/dist/emit.js.map +1 -0
  104. package/dist/facts.d.ts +92 -0
  105. package/dist/facts.d.ts.map +1 -0
  106. package/dist/facts.js +3 -0
  107. package/dist/facts.js.map +1 -0
  108. package/dist/files.d.ts +4 -0
  109. package/dist/files.d.ts.map +1 -0
  110. package/dist/files.js +15 -0
  111. package/dist/files.js.map +1 -0
  112. package/dist/formCode.d.ts +6 -0
  113. package/dist/formCode.d.ts.map +1 -0
  114. package/dist/formCode.js +46 -0
  115. package/dist/formCode.js.map +1 -0
  116. package/dist/generate.d.ts +7 -0
  117. package/dist/generate.d.ts.map +1 -0
  118. package/dist/generate.js +69 -0
  119. package/dist/generate.js.map +1 -0
  120. package/dist/generateRound.d.ts +14 -0
  121. package/dist/generateRound.d.ts.map +1 -0
  122. package/dist/generateRound.js +44 -0
  123. package/dist/generateRound.js.map +1 -0
  124. package/dist/importNames.d.ts +7 -0
  125. package/dist/importNames.d.ts.map +1 -0
  126. package/dist/importNames.js +57 -0
  127. package/dist/importNames.js.map +1 -0
  128. package/dist/inputs.d.ts +15 -0
  129. package/dist/inputs.d.ts.map +1 -0
  130. package/dist/inputs.js +64 -0
  131. package/dist/inputs.js.map +1 -0
  132. package/dist/ownerOf.d.ts +7 -0
  133. package/dist/ownerOf.d.ts.map +1 -0
  134. package/dist/ownerOf.js +30 -0
  135. package/dist/ownerOf.js.map +1 -0
  136. package/dist/packageScope.d.ts +13 -0
  137. package/dist/packageScope.d.ts.map +1 -0
  138. package/dist/packageScope.js +28 -0
  139. package/dist/packageScope.js.map +1 -0
  140. package/dist/plan.d.ts +12 -0
  141. package/dist/plan.d.ts.map +1 -0
  142. package/dist/plan.js +115 -0
  143. package/dist/plan.js.map +1 -0
  144. package/dist/project.d.ts +11 -0
  145. package/dist/project.d.ts.map +1 -0
  146. package/dist/project.js +24 -0
  147. package/dist/project.js.map +1 -0
  148. package/dist/rejections.d.ts +11 -0
  149. package/dist/rejections.d.ts.map +1 -0
  150. package/dist/rejections.js +46 -0
  151. package/dist/rejections.js.map +1 -0
  152. package/dist/report.d.ts +22 -0
  153. package/dist/report.d.ts.map +1 -0
  154. package/dist/report.js +20 -0
  155. package/dist/report.js.map +1 -0
  156. package/dist/serverCode.d.ts +20 -0
  157. package/dist/serverCode.d.ts.map +1 -0
  158. package/dist/serverCode.js +90 -0
  159. package/dist/serverCode.js.map +1 -0
  160. package/dist/settleOrphans.d.ts +5 -0
  161. package/dist/settleOrphans.d.ts.map +1 -0
  162. package/dist/settleOrphans.js +43 -0
  163. package/dist/settleOrphans.js.map +1 -0
  164. package/dist/specifiers.d.ts +4 -0
  165. package/dist/specifiers.d.ts.map +1 -0
  166. package/dist/specifiers.js +52 -0
  167. package/dist/specifiers.js.map +1 -0
  168. package/package.json +82 -5
  169. package/src/BuildFailure.ts +5 -0
  170. package/src/adapters/appTestConfig.ts +18 -0
  171. package/src/adapters/appViteConfig.ts +31 -0
  172. package/src/adapters/bundleCheck.ts +57 -0
  173. package/src/adapters/copiedCode.ts +103 -0
  174. package/src/adapters/failuresOf.ts +31 -0
  175. package/src/adapters/isReadonlyProperty.ts +21 -0
  176. package/src/adapters/moduleCalls.ts +58 -0
  177. package/src/adapters/moduleFacts.ts +66 -0
  178. package/src/adapters/objectForm.ts +74 -0
  179. package/src/adapters/operationFacts.ts +85 -0
  180. package/src/adapters/runNode.ts +15 -0
  181. package/src/adapters/typeReading.ts +63 -0
  182. package/src/adapters/typeScript.ts +101 -0
  183. package/src/adapters/unionForm.ts +88 -0
  184. package/src/adapters/vitePlugin.ts +103 -0
  185. package/src/adapters/vitePort.ts +5 -0
  186. package/src/adapters/vitest/setup.ts +12 -0
  187. package/src/adapters/wireForm.ts +98 -0
  188. package/src/byText.ts +7 -0
  189. package/src/cache.ts +28 -0
  190. package/src/cli.ts +16 -0
  191. package/src/code.ts +23 -0
  192. package/src/command.ts +42 -0
  193. package/src/emit.ts +117 -0
  194. package/src/facts.ts +70 -0
  195. package/src/files.ts +19 -0
  196. package/src/formCode.ts +59 -0
  197. package/src/generate.ts +86 -0
  198. package/src/generateRound.ts +60 -0
  199. package/src/importNames.ts +61 -0
  200. package/src/inputs.ts +82 -0
  201. package/src/ownerOf.ts +40 -0
  202. package/src/packageScope.ts +35 -0
  203. package/src/plan.ts +138 -0
  204. package/src/project.ts +30 -0
  205. package/src/rejections.ts +57 -0
  206. package/src/report.ts +39 -0
  207. package/src/serverCode.ts +139 -0
  208. package/src/settleOrphans.ts +58 -0
  209. package/src/specifiers.ts +55 -0
@@ -0,0 +1,35 @@
1
+ import { Effect, FileSystem, Option, Path, Schema } from "effect";
2
+
3
+ const Manifest = Schema.Struct({
4
+ exports: Schema.optionalKey(Schema.Unknown),
5
+ imports: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
6
+ name: Schema.optionalKey(Schema.String),
7
+ });
8
+
9
+ export interface PackageScope {
10
+ readonly directory: string;
11
+ readonly manifest: typeof Manifest.Type;
12
+ }
13
+
14
+ const decodeManifest = Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest));
15
+
16
+ // The nearest named package.json above a file decides how the file is imported; a nameless one only sets a folder's module type.
17
+ export const packageScopeOf = Effect.fn("Build.packageScopeOf")(function* (file: string) {
18
+ const fs = yield* FileSystem.FileSystem;
19
+ const path = yield* Path.Path;
20
+ let directory = path.dirname(file);
21
+ for (;;) {
22
+ const manifestPath = path.join(directory, "package.json");
23
+ if (yield* fs.exists(manifestPath)) {
24
+ const manifest = yield* decodeManifest(yield* fs.readFileString(manifestPath));
25
+ if (manifest.name !== undefined) {
26
+ return Option.some<PackageScope>({ directory, manifest });
27
+ }
28
+ }
29
+ const parent = path.dirname(directory);
30
+ if (parent === directory) {
31
+ return Option.none<PackageScope>();
32
+ }
33
+ directory = parent;
34
+ }
35
+ });
package/src/plan.ts ADDED
@@ -0,0 +1,138 @@
1
+ import { Effect, Option, Path } from "effect";
2
+ import { byText } from "#byText.ts";
3
+ import { code, type ImportRef } from "#code.ts";
4
+ import type { PlannedModule, PlannedOperation } from "#emit.ts";
5
+ import type { CodePart, DeclaredClass, ModuleFacts, OperationFacts, WireForm } from "#facts.ts";
6
+ import { formCode } from "#formCode.ts";
7
+ import { packageScopeOf } from "#packageScope.ts";
8
+ import { rejectionsOf } from "#rejections.ts";
9
+ import { specifierFor } from "#specifiers.ts";
10
+
11
+ export interface Placement {
12
+ readonly output: string;
13
+ readonly root: string;
14
+ }
15
+
16
+ function classesIn(form: WireForm | undefined): readonly DeclaredClass[] {
17
+ if (form === undefined) {
18
+ return [];
19
+ }
20
+ switch (form.form) {
21
+ case "class":
22
+ return [form.declared];
23
+ case "array":
24
+ return classesIn(form.item);
25
+ case "tuple":
26
+ return form.elements.flatMap(classesIn);
27
+ case "struct":
28
+ return form.fields.flatMap((field) => classesIn(field.form));
29
+ case "record":
30
+ return classesIn(form.value);
31
+ case "union":
32
+ return form.members.flatMap(classesIn);
33
+ case "nullOr":
34
+ case "option":
35
+ return classesIn(form.inner);
36
+ default:
37
+ return [];
38
+ }
39
+ }
40
+
41
+ const classRef = Effect.fn("Build.classRef")(function* (placement: Placement, declared: DeclaredClass) {
42
+ const specifier = yield* specifierFor(placement.root, placement.output, declared.file);
43
+ const name = declared.exportName ?? declared.className;
44
+ return specifier === undefined ? undefined : { name, preferred: name, specifier, typeOnly: false };
45
+ });
46
+
47
+ const codeOf = Effect.fn("Build.codeOf")(function* (placement: Placement, parts: readonly CodePart[]) {
48
+ const path = yield* Path.Path;
49
+ return yield* Effect.forEach(parts, (part) =>
50
+ Effect.gen(function* () {
51
+ if (typeof part === "string") {
52
+ return part;
53
+ }
54
+ const own = !path.relative(placement.root, part.writtenIn).startsWith("..");
55
+ const specifier =
56
+ own || !part.specifier.startsWith("#") || part.target === undefined
57
+ ? part.specifier
58
+ : yield* specifierFor(placement.root, placement.output, part.target);
59
+ return { name: part.name, preferred: part.local, specifier: specifier ?? part.specifier, typeOnly: part.typeOnly } satisfies ImportRef;
60
+ }),
61
+ );
62
+ });
63
+
64
+ function copiedOf(placement: Placement, parts: readonly CodePart[] | undefined) {
65
+ return parts === undefined ? Effect.succeed(undefined) : codeOf(placement, parts);
66
+ }
67
+
68
+ const operationPlan = Effect.fn("Build.operationPlan")(function* (
69
+ placement: Placement,
70
+ label: string,
71
+ operation: OperationFacts,
72
+ refuse: (message: string) => void,
73
+ ) {
74
+ const packages = new Map<string, string | undefined>();
75
+ for (const failure of operation.failures) {
76
+ if (failure.kind === "class" && !packages.has(failure.declared.file)) {
77
+ const scope = yield* packageScopeOf(failure.declared.file);
78
+ packages.set(failure.declared.file, Option.getOrUndefined(Option.map(scope, ({ manifest }) => manifest.name)));
79
+ }
80
+ }
81
+ const { refusals, rejections } = rejectionsOf(label, operation.failures, (file) => packages.get(file));
82
+ refusals.forEach(refuse);
83
+ const refs = new Map<string, ImportRef>();
84
+ for (const declared of [...classesIn(operation.success), ...rejections.map((rejection) => rejection.declared)]) {
85
+ const ref = yield* classRef(placement, declared);
86
+ if (ref === undefined) {
87
+ refuse(
88
+ `${label}: ${declared.className} in ${declared.file} cannot be imported by the generated file; export it from a module its package exports`,
89
+ );
90
+ } else {
91
+ refs.set(`${declared.file}\u0000${declared.className}`, ref);
92
+ }
93
+ }
94
+ function refOf(declared: DeclaredClass): ImportRef {
95
+ return (
96
+ refs.get(`${declared.file}\u0000${declared.className}`) ?? {
97
+ name: declared.className,
98
+ preferred: declared.className,
99
+ specifier: declared.file,
100
+ typeOnly: false,
101
+ }
102
+ );
103
+ }
104
+ const members = yield* Effect.forEach([operation.reads, operation.readsCollections, operation.invalidates], (parts) => copiedOf(placement, parts));
105
+ // A browser contract's query always carries reads, so a query that declares no keys gets none, as query() gives it.
106
+ const reads = operation.kind === "query" && operation.reads === undefined ? [code("reads: () => []")] : [];
107
+ const planned: PlannedOperation = {
108
+ kind: operation.kind,
109
+ members: [...reads, ...members.filter((member) => member !== undefined)],
110
+ name: operation.name,
111
+ payload: yield* copiedOf(placement, operation.payload),
112
+ rejections: rejections.map(({ declared, tag }) => [tag, refOf(declared)] as const),
113
+ success: operation.success === undefined ? code() : formCode(operation.success, (form) => refOf(form.declared)),
114
+ };
115
+ return planned;
116
+ });
117
+
118
+ export const plan = Effect.fn("Build.plan")(function* (placement: Placement, facts: ModuleFacts) {
119
+ const path = yield* Path.Path;
120
+ const refusals: string[] = [...facts.refusals];
121
+ function refuse(message: string) {
122
+ refusals.push(message);
123
+ }
124
+ const sorted = [...facts.operations].sort((left, right) => byText(left.name, right.name));
125
+ const operations = yield* Effect.forEach(sorted, (operation) => operationPlan(placement, `${facts.name}.${operation.name}`, operation, refuse));
126
+ const moduleSpecifier = (yield* specifierFor(placement.root, placement.output, facts.file)) ?? facts.file;
127
+ const planned: PlannedModule = {
128
+ exportName: facts.exportName,
129
+ keyScope: yield* copiedOf(placement, facts.keyScope),
130
+ middleware: yield* copiedOf(placement, facts.middleware),
131
+ module: { name: facts.exportName, preferred: facts.exportName, specifier: moduleSpecifier, typeOnly: true },
132
+ name: facts.name,
133
+ operations,
134
+ source: path.relative(placement.root, facts.file),
135
+ subject: yield* copiedOf(placement, facts.subject),
136
+ };
137
+ return { planned, refusals: [...new Set(refusals)] };
138
+ });
package/src/project.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+ import { definesModule, readConfig } from "#adapters/typeScript.ts";
3
+ import { generatorFingerprint, type Project } from "#inputs.ts";
4
+
5
+ export interface ModuleFile {
6
+ readonly module: string;
7
+ readonly output: string;
8
+ }
9
+
10
+ export const openProject = Effect.fn("Build.openProject")(function* (directory: string) {
11
+ const fs = yield* FileSystem.FileSystem;
12
+ const path = yield* Path.Path;
13
+ const root = yield* fs.realPath(directory);
14
+ const config = yield* readConfig(path.join(root, "tsconfig.json"));
15
+ const project: Project = { config, generated: `${path.join(root, "src", "generated")}${path.sep}`, generator: yield* generatorFingerprint(), root };
16
+ return project;
17
+ });
18
+
19
+ // Every file of the tsconfig that calls defineModule, so no list of modules needs keeping; each writes src/generated/<its path under src>.
20
+ export const moduleFiles = Effect.fn("Build.moduleFiles")(function* (project: Project) {
21
+ const fs = yield* FileSystem.FileSystem;
22
+ const path = yield* Path.Path;
23
+ const sources = project.config.files.filter((file) => !(file.startsWith(project.generated) || file.endsWith(".d.ts"))).sort();
24
+ const modules = yield* Effect.filter(sources, (file) => Effect.map(fs.readFileString(file), (text) => definesModule(file, text)));
25
+ const source = path.join(project.root, "src");
26
+ return modules.map((module): ModuleFile & { readonly underSource: boolean } => {
27
+ const underSource = path.relative(source, module);
28
+ return { module, output: path.join(project.generated, underSource), underSource: !underSource.startsWith("..") };
29
+ });
30
+ });
@@ -0,0 +1,57 @@
1
+ import { byText } from "#byText.ts";
2
+ import type { DeclaredClass, Failure } from "#facts.ts";
3
+
4
+ // Never rejections: the wire adds the automatic two to every operation, and the others are infrastructure or policy outcomes.
5
+ const NOT_REJECTIONS: ReadonlySet<string> = new Set([
6
+ "@shivaedev/contract QueryReadDenied",
7
+ "@shivaedev/errors InternalServerError",
8
+ "@shivaedev/errors ServiceUnavailable",
9
+ "effect SchemaError",
10
+ ]);
11
+
12
+ export interface Rejection {
13
+ readonly declared: DeclaredClass;
14
+ readonly tag: string;
15
+ }
16
+
17
+ export function isRejection(packageName: string | undefined, declared: DeclaredClass): boolean {
18
+ return !NOT_REJECTIONS.has(`${packageName ?? ""} ${declared.className}`);
19
+ }
20
+
21
+ function refusalOf(label: string, failure: Failure): string | undefined {
22
+ switch (failure.kind) {
23
+ case "untagged":
24
+ return `${label}: run fails with ${failure.text}, which has no _tag; fail with an exported tagged error class instead`;
25
+ case "unexported":
26
+ return `${label}: run fails with ${failure.text} (${failure.tag}), which is not an exported class; export its class so the browser can decode it`;
27
+ case "class":
28
+ return failure.schema
29
+ ? undefined
30
+ : `${label}: run fails with ${failure.declared.className} (${failure.tag}), which is not a Schema class, so the browser cannot decode it; declare it as a Schema tagged error, such as class ${failure.declared.className} extends Schema.TaggedError<${failure.declared.className}>()("${failure.tag}", { ... })`;
31
+ case "retryable":
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ export function rejectionsOf(label: string, failures: readonly Failure[], packageOf: (file: string) => string | undefined) {
37
+ const refusals: string[] = [];
38
+ const byTag = new Map<string, Rejection>();
39
+ for (const failure of failures) {
40
+ const rejection = failure.kind !== "class" || isRejection(packageOf(failure.declared.file), failure.declared);
41
+ const refusal = rejection ? refusalOf(label, failure) : undefined;
42
+ if (refusal !== undefined) {
43
+ refusals.push(refusal);
44
+ }
45
+ if (failure.kind === "class" && rejection) {
46
+ const known = byTag.get(failure.tag);
47
+ if (known !== undefined && (known.declared.file !== failure.declared.file || known.declared.className !== failure.declared.className)) {
48
+ refusals.push(
49
+ `${label}: run fails with two classes tagged ${failure.tag}, from ${known.declared.file} and ${failure.declared.file}; raise one of them`,
50
+ );
51
+ }
52
+ byTag.set(failure.tag, failure);
53
+ }
54
+ }
55
+ const rejections = [...byTag.values()].sort((left, right) => byText(left.tag, right.tag));
56
+ return { refusals, rejections };
57
+ }
package/src/report.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { Data } from "effect";
2
+
3
+ // current: rebuilt and equal to the file; unchanged: inputs as last time, so no program was built; removed and, under
4
+ // --check, orphaned: a generated file whose module is gone.
5
+ export type ModuleStatus = "current" | "missing" | "orphaned" | "refused" | "removed" | "stale" | "unchanged" | "written";
6
+
7
+ export interface ModuleOutcome {
8
+ readonly module: string;
9
+ readonly output: string;
10
+ readonly status: ModuleStatus;
11
+ }
12
+
13
+ export interface GenerateReport {
14
+ readonly inputs: readonly string[];
15
+ readonly modules: readonly ModuleOutcome[];
16
+ readonly programBuilt: boolean;
17
+ }
18
+
19
+ export function problemOf({ module, output, status }: ModuleOutcome): string | undefined {
20
+ switch (status) {
21
+ case "missing":
22
+ case "stale":
23
+ return `${output} is ${status}; run \`pnpm generate\``;
24
+ case "orphaned":
25
+ return `${output} is left from ${module}, which defines no module any more; run \`pnpm generate\``;
26
+ case "current":
27
+ case "refused":
28
+ case "removed":
29
+ case "unchanged":
30
+ case "written":
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ // A refused module, or under --check a missing, stale or orphaned file: the command prints each problem and exits 1.
36
+ export class GenerateFailed extends Data.TaggedError("GenerateFailed")<{
37
+ readonly problems: readonly string[];
38
+ readonly report: GenerateReport;
39
+ }> {}
@@ -0,0 +1,139 @@
1
+ import { Effect, Option, Path } from "effect";
2
+ import { type Owner, ownerOf } from "#ownerOf.ts";
3
+
4
+ export interface BundleModule {
5
+ readonly builtin: boolean;
6
+ readonly imports: readonly string[];
7
+ }
8
+
9
+ export interface BundleGraph {
10
+ readonly entries: readonly string[];
11
+ readonly modules: ReadonlyMap<string, BundleModule>;
12
+ }
13
+
14
+ export interface ServerMarkers {
15
+ readonly defineModule: Option.Option<string>;
16
+ readonly operation: Option.Option<string>;
17
+ readonly sql: Option.Option<string>;
18
+ }
19
+
20
+ export interface ServerCodeOptions {
21
+ readonly root: string;
22
+ readonly serverOnly: readonly string[];
23
+ }
24
+
25
+ const displayed = Effect.fn("Build.displayed")(function* (id: string, options: ServerCodeOptions, owners: Map<string, Option.Option<Owner>>) {
26
+ const path = yield* Path.Path;
27
+ const relative = path.relative(options.root, id);
28
+ if (path.isAbsolute(id) && !relative.startsWith("..") && !relative.split(path.sep).includes("node_modules")) {
29
+ return relative;
30
+ }
31
+ const owner = yield* ownerOf(id, owners);
32
+ return Option.match(owner, {
33
+ onNone: () => id.replace(/^__vite-browser-external:/u, ""),
34
+ onSome: ({ directory, name }) => `${name}/${path.relative(directory, id)}`,
35
+ });
36
+ });
37
+
38
+ const reasonOf = Effect.fn("Build.reasonOf")(function* (
39
+ id: string,
40
+ module: BundleModule,
41
+ markers: ServerMarkers,
42
+ options: ServerCodeOptions,
43
+ owners: Map<string, Option.Option<Owner>>,
44
+ ) {
45
+ const path = yield* Path.Path;
46
+ if (module.builtin) {
47
+ return Option.some("a Node builtin");
48
+ }
49
+ if (Option.exists(markers.defineModule, (marker) => module.imports.includes(marker))) {
50
+ return Option.some("a module file: it calls defineModule");
51
+ }
52
+ if (Option.exists(markers.operation, (marker) => module.imports.includes(marker))) {
53
+ return Option.some("an operation file: it defines a query or a command");
54
+ }
55
+ const owner = yield* ownerOf(id, owners);
56
+ if (Option.isSome(owner) && options.serverOnly.includes(owner.value.name)) {
57
+ return Option.some(`part of ${owner.value.name}, which is listed as server-only`);
58
+ }
59
+ const folders = Option.match(owner, { onNone: () => "", onSome: ({ directory }) => path.dirname(path.relative(directory, id)) });
60
+ if (folders.split(path.sep).includes("server")) {
61
+ return Option.some("under a server/ folder");
62
+ }
63
+ return Option.none<string>();
64
+ });
65
+
66
+ // Effect's own index re-exports modules that import effect/sql, which tree shaking drops, so the import edge decides: every
67
+ // importer of effect/sql outside Effect counts, however many paths inside Effect reach the same module sooner.
68
+ const importsSqlFromOutside = Effect.fn("Build.importsSqlFromOutside")(function* (
69
+ importer: string,
70
+ imported: string,
71
+ markers: ServerMarkers,
72
+ owners: Map<string, Option.Option<Owner>>,
73
+ ) {
74
+ const path = yield* Path.Path;
75
+ if (!Option.exists(markers.sql, (directory) => imported.startsWith(`${directory}${path.sep}`))) {
76
+ return false;
77
+ }
78
+ const sqlOwner = yield* ownerOf(imported, owners);
79
+ const importerOwner = yield* ownerOf(importer, owners);
80
+ return !Option.exists(importerOwner, (each) => Option.exists(sqlOwner, ({ directory }) => each.directory === directory));
81
+ });
82
+
83
+ const failure = Effect.fn("Build.failure")(function* (
84
+ chain: readonly string[],
85
+ reason: string,
86
+ options: ServerCodeOptions,
87
+ owners: Map<string, Option.Option<Owner>>,
88
+ ) {
89
+ const shown = yield* Effect.forEach(chain, (each) => displayed(each, options, owners));
90
+ return [
91
+ `Server code reached the browser bundle: ${shown.at(-1)} is ${reason}. The import chain from the client entry:`,
92
+ ...shown.map((each, index) => (index === 0 ? ` ${each}` : ` → ${each}`)),
93
+ "The browser reaches a defined module through its generated client in src/generated and imports no server code.",
94
+ ].join("\n");
95
+ });
96
+
97
+ function chainTo(id: string, parents: ReadonlyMap<string, string | undefined>): readonly string[] {
98
+ const chain: string[] = [];
99
+ for (let each: string | undefined = id; each !== undefined; each = parents.get(each)) {
100
+ chain.unshift(each);
101
+ }
102
+ return chain;
103
+ }
104
+
105
+ interface Walk {
106
+ readonly owners: Map<string, Option.Option<Owner>>;
107
+ readonly parents: Map<string, string | undefined>;
108
+ readonly pending: string[];
109
+ }
110
+
111
+ const visit = Effect.fn("Build.visit")(function* (id: string, module: BundleModule, walk: Walk, markers: ServerMarkers, options: ServerCodeOptions) {
112
+ const reason = yield* reasonOf(id, module, markers, options, walk.owners);
113
+ if (Option.isSome(reason)) {
114
+ return Option.some(yield* failure(chainTo(id, walk.parents), reason.value, options, walk.owners));
115
+ }
116
+ for (const imported of module.imports) {
117
+ if (yield* importsSqlFromOutside(id, imported, markers, walk.owners)) {
118
+ return Option.some(yield* failure([...chainTo(id, walk.parents), imported], "Effect's SQL client, effect/sql", options, walk.owners));
119
+ }
120
+ if (!walk.parents.has(imported)) {
121
+ walk.parents.set(imported, id);
122
+ walk.pending.push(imported);
123
+ }
124
+ }
125
+ return Option.none<string>();
126
+ });
127
+
128
+ // Breadth first from the entries, so the chain printed is a shortest one to the nearest server module.
129
+ export const serverCodeIn = Effect.fn("Build.serverCodeIn")(function* (graph: BundleGraph, markers: ServerMarkers, options: ServerCodeOptions) {
130
+ const walk: Walk = { owners: new Map(), parents: new Map(graph.entries.map((entry) => [entry, undefined])), pending: [...graph.entries] };
131
+ for (let id = walk.pending.shift(); id !== undefined; id = walk.pending.shift()) {
132
+ const module = graph.modules.get(id);
133
+ const found = module === undefined ? Option.none<string>() : yield* visit(id, module, walk, markers, options);
134
+ if (Option.isSome(found)) {
135
+ return found;
136
+ }
137
+ }
138
+ return Option.none<string>();
139
+ });
@@ -0,0 +1,58 @@
1
+ import { Effect, FileSystem, Option, Path } from "effect";
2
+ import { definesModule } from "#adapters/typeScript.ts";
3
+ import { generatedFrom } from "#emit.ts";
4
+ import { readIfPresent } from "#files.ts";
5
+ import type { Project } from "#inputs.ts";
6
+ import type { ModuleOutcome } from "#report.ts";
7
+
8
+ interface Orphan {
9
+ readonly file: string;
10
+ readonly source: string;
11
+ }
12
+
13
+ const definesModuleStill = Effect.fn("Build.definesModuleStill")(function* (project: Project, source: string) {
14
+ const path = yield* Path.Path;
15
+ const module = path.join(project.root, source);
16
+ return Option.exists(yield* readIfPresent(module), (text) => definesModule(module, text));
17
+ });
18
+
19
+ // Decided by the module the header names, never by comparing paths: on a case-insensitive file system a folder renamed by
20
+ // case alone lists the module's own file under its old spelling.
21
+ const orphansOf = Effect.fn("Build.orphansOf")(function* (project: Project) {
22
+ const fs = yield* FileSystem.FileSystem;
23
+ const path = yield* Path.Path;
24
+ if (!(yield* fs.exists(project.generated))) {
25
+ return [];
26
+ }
27
+ const entries = (yield* fs.readDirectory(project.generated, { recursive: true })).map((entry) => path.join(project.generated, entry)).sort();
28
+ const orphans = yield* Effect.forEach(
29
+ entries.filter((file) => file.endsWith(".ts")),
30
+ (file) =>
31
+ Effect.gen(function* () {
32
+ if ((yield* fs.stat(file)).type !== "File") {
33
+ return [];
34
+ }
35
+ const source = Option.flatMap(yield* readIfPresent(file), (text) => Option.fromUndefinedOr(generatedFrom(text)));
36
+ return Option.isSome(source) && !(yield* definesModuleStill(project, source.value)) ? [{ file, source: source.value } satisfies Orphan] : [];
37
+ }),
38
+ );
39
+ return orphans.flat();
40
+ });
41
+
42
+ export const settleOrphans = Effect.fn("Build.settleOrphans")(function* (project: Project, check: boolean) {
43
+ const fs = yield* FileSystem.FileSystem;
44
+ const path = yield* Path.Path;
45
+ return yield* Effect.forEach(yield* orphansOf(project), (orphan) =>
46
+ Effect.gen(function* () {
47
+ if (!check) {
48
+ yield* fs.remove(orphan.file);
49
+ }
50
+ const outcome: ModuleOutcome = {
51
+ module: orphan.source,
52
+ output: path.relative(project.root, orphan.file),
53
+ status: check ? "orphaned" : "removed",
54
+ };
55
+ return outcome;
56
+ }),
57
+ );
58
+ });
@@ -0,0 +1,55 @@
1
+ import { Effect, Option, Path } from "effect";
2
+ import { packageScopeOf } from "#packageScope.ts";
3
+
4
+ function targetsOf(value: unknown): readonly string[] {
5
+ if (typeof value === "string") {
6
+ return [value];
7
+ }
8
+ if (Array.isArray(value)) {
9
+ return value.flatMap(targetsOf);
10
+ }
11
+ return typeof value === "object" && value !== null ? Object.values(value).flatMap(targetsOf) : [];
12
+ }
13
+
14
+ function exportEntries(exports: unknown): readonly (readonly [string, unknown])[] {
15
+ if (typeof exports !== "object" || exports === null || Array.isArray(exports)) {
16
+ return exports === undefined ? [] : [[".", exports]];
17
+ }
18
+ const entries = Object.entries(exports);
19
+ return entries.every(([key]) => key.startsWith(".")) ? entries : [[".", exports]];
20
+ }
21
+
22
+ // The key of the first entry whose target names the file, with its wildcard filled in.
23
+ export function matchingKey(entries: readonly (readonly [string, unknown])[], relativeFile: string): string | undefined {
24
+ for (const [key, value] of entries) {
25
+ for (const target of targetsOf(value)) {
26
+ const [prefix = "", suffix, ...rest] = target.split("*");
27
+ if (suffix === undefined && target === relativeFile) {
28
+ return key;
29
+ }
30
+ const fits = suffix !== undefined && rest.length === 0 && relativeFile.length >= prefix.length + suffix.length;
31
+ if (fits && relativeFile.startsWith(prefix) && relativeFile.endsWith(suffix)) {
32
+ return key.replace("*", relativeFile.slice(prefix.length, relativeFile.length - suffix.length));
33
+ }
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ // How the generated file at `from` imports `file`: the project's own imports, another package's exports, else a relative path.
40
+ export const specifierFor = Effect.fn("Build.specifierFor")(function* (root: string, from: string, file: string) {
41
+ const path = yield* Path.Path;
42
+ const scope = yield* packageScopeOf(file);
43
+ if (Option.isNone(scope)) {
44
+ return undefined;
45
+ }
46
+ const { directory, manifest } = scope.value;
47
+ const relativeFile = `./${path.relative(directory, file)}`;
48
+ if (directory === root) {
49
+ const own = matchingKey(Object.entries(manifest.imports ?? {}), relativeFile);
50
+ const relative = path.relative(path.dirname(from), file);
51
+ return own ?? (relative.startsWith(".") ? relative : `./${relative}`);
52
+ }
53
+ const exported = matchingKey(exportEntries(manifest.exports), relativeFile);
54
+ return exported === undefined || manifest.name === undefined ? undefined : `${manifest.name}${exported.slice(1)}`;
55
+ });