diff --git a/AGENTS.md b/AGENTS.md index f72f056..572aa7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,17 +46,23 @@ title and body as a draft and stop there. ## Releasing 1. Write the release notes for users, not reviewers, in a file outside the repo. -2. Run `vp run bump-version --body-file notes.md --release --yes`, where `` is the +2. Set `#valof` in `package.json` to `./src/index.ts`, run `vp check` and `vp run bundle-size`, and + set it back to `valof-release`. Report what breaks, and where the new API is awkward to use in + the scripts. A user encounters both when upgrading. If a problem is serious, stop the release and + rework the design. +3. Run `vp run bump-version --body-file notes.md --release --yes`, where `` is the release type I give, such as `patch`. It pushes a release branch and opens its PR with the file as the body. Options other than `--body-file` go to bumpp. -3. If CI reports stale bundle-size claims, run +4. If CI reports stale bundle-size claims, run `vp run replace-bundle-size-claims --write README.md package.json` on that branch, commit, and push. -4. I merge the PR. The release workflow tags the merge commit, publishes to npm, creates the GitHub +5. I merge the PR. The release workflow tags the merge commit, publishes to npm, creates the GitHub release, and publishes the book. The notes start with the PR body, followed by the PRs merged since the last tag. +6. Once npm has the new version, set `valof-release` in `package.json` to it, and fix the scripts + where step 2 found a break. -Steps 2 and 3 push, and step 2 opens a PR. They are the only exception to "Never push, and never +Steps 3 and 4 push, and step 3 opens a PR. They are the only exception to "Never push, and never open a PR" above. Run them only on my explicit go-ahead. ## Writing Style diff --git a/notes/design.md b/notes/design.md index 161fa79..d1814d4 100644 --- a/notes/design.md +++ b/notes/design.md @@ -39,7 +39,7 @@ import { Val } from "valof"; - **15.2 `Enum`** §7.4 の見直し。Variant をレコードに宣言して union を導出、ブランドの導出、タグ名のカスタムと `tag-mismatch`、companion に置く `match`、ts-pattern との線引き、型を確かめた記録(共通フィールド、`match` の型引数、`VariantOf` の表示、却下した戻り値の型引数・自由関数の `match`・Val のレコード、Trait の実装、タグ名を `Tag<…>` で渡すこと、トップレベルの条件型が宣言出力を壊すこと)、実装して分かったこと(Fault の置き場所、Variant 1 個の禁止、`then` を 3 箇所で落とす、宣言出力の CI、variance 測定と型コスト)、Variant ごとの seal と union の `seal`(入口を 2 つに分ける、builder を callback で渡す、`impl` で鎖を閉じる、却下した値の形)、`seal` の引数を排他的にしたこと、タグだけで分ける `[Tag].match` と `[Tag].key`(キーを文字列にする理由、却下した名前)、`implVariant` を Variant ごとの鎖にしたこと、steps を枠そのものにしたこと、タグ名の渡し方を変える 4 案の却下、companion を渡さない形(val.ts の門を共有する、`PayloadKeys` の分配、入口 2 つぶんのテスト) - **15.3 `path`** seal をまたぐ patch の合成。`abort` を合成側に置く判断、ハンドラが最終段である理由(HKT)、`glue` の `open` / `close`、`each` / `where`、却下した `deepPatch` - **15.4 `.impl` のコールバック形** 自分の companion を参照すると推論が回らない(TS7022)。contextual typing がコールバック越しでも効くことの実測、`implTrait` も callback、予算、却下したメンバのカリー化と、下流の `.impl` を型で塞ぐ案。**鎖は 2026-09-18 に閉じた**(1 回だけ、兄弟は注釈で呼ぶ、Trait の実装の受け手を `Tr` に、却下した型レベルの案内と lint 規則) -- **§16 予算の責務** バンドルと型を別のスクリプトに割る。宣言のバイト数を type-perf へ、予算を 64 kB に上げた理由 +- **§16 予算の責務** バンドルと型を別のスクリプトに割る。宣言のバイト数を type-perf へ、予算を 64 kB に上げた理由、16.1 バンドルの増分を実測の core から測る、16.2 production の検査はサイズを比べる - **§17 Effect v4 調査** 落ちた typeclass と HKT の層(§15.3 の根拠)、値の中に等価性を置く形、クラスの構造比較に開く穴、向こうにあってこちらに無いもの --- @@ -6532,6 +6532,79 @@ type-perf に移せる理由でもある。 `index.d.mts` は entry を分けた後も同じ読み方をする。entry ファイル自体は再 export 2 行で 323 B しかなく、 中身は共有チャンクにあるので、type-perf は import を辿って合算する。 +### 16.1 増分は実測の core から測る、2026-10-15 + +**`1024 + 256` の書き方は、core の実測が増えたときの関係を保たなかった。**比べていたのは各エントリの実測と +「core の予算 + 増分」で、各エントリは core の予算の余りを借りて通っていた。 + +| | 実測 | 予算 | core からの増分 | 名目上の増分 | +| ---------- | ------- | ---------- | --------------- | ------------ | +| `Val` | 896 B | 1024 | | | +| + `equals` | 1,083 B | 1024 + 128 | **+187** | +128 | +| + `Trait` | 978 B | 1024 + 256 | +82 | +256 | +| + `Enum` | 1,370 B | 1024 + 384 | **+474** | +384 | +| 全部 | 1,633 B | 1024 + 640 | **+737** | +640 | + +`.implCreate.noseal`(#65)で core が 44 B 増え、全部の余りが 31 B、`Enum` の余りが 38 B になった。core が +1 B 増えれば、全部の余りも 1 B 減る。§16 の「core の予算を動かしたときも関係が保たれる」と合わない。 + +- **core だけを実測と比べる。**ほかは `エントリの実測 - core の実測` を増分の予算と比べる。core の増加は + core の予算でだけ止まる +- **増分を決め直した。**`equals` +256、`Trait` +256(据え置き)、`Enum` +640、全部 +1024。どれも 26% 以上の + 余りがある +- **予算の表は増分を印字する。**`added by Enum +474 B / +640 B` + +**却下: 仕組みはそのままで、数値だけ上げる。**`Enum` を +512、全部を +768 にする案。core が増えるたびに、 +また狭くなる。 + +**却下: `Enum` を +512 にする。**区切りはよいが、余りが 38 B(7%)で、直す前と変わらない。`match` の `_` が +実行時のコードを足すので、その PR で上げ直すことになる。 + +### 16.2 production の検査はサイズを比べる、2026-10-15 + +**前の検査は文字列を探していた。**production の core に `snapshotSealInput` など development 用の +文字列 3 つが残っていないか、`equals` を import しないバンドルに `Number.isNaN` が残っていないかを見ていた。 +関数名とメッセージが変わると黙って効かなくなり、新しい development 用のコードは書き足さないと見ない。 + +**`Number.isNaN` は `equals` の目印になっていなかった。**`development` を `true` に固定すると、`equals` を +import しない core で「equals が残っている」と出た。development 用のコードも `Number.isNaN` を使う。 + +今は 2 つの検査とも、特定の名前や文字列に頼らず、サイズで比べる。 + +| 検査 | 失敗する条件 | +| ------------------ | ---------------------------------------------------------------------------------------------------- | +| `DevCodeKeptError` | バンドルに入ったソースが `NODE_ENV` を読むのに、production と development の minify 後のサイズが同じ | +| `AddsNothingError` | ベースの上に積んだバンドルのライブラリ部分が、ベースと同じ長さ。足した import はベースに入っている | + +- **ライブラリ部分は「チャンクの長さ − エントリの `renderedLength`」で測る。**エントリは import した名前を + `console.log` に渡すので、全体を比べると名前の分だけ必ず違う。各モジュールの `renderedLength` は + tree-shaking の前の長さで、`equals` の有無で同じ 13,923 だった。使えない +- **`NODE_ENV` を読むかどうかは、`define` が置き換える前のソースで見る。**ビルド後のコードには残らない + +壊して確かめた結果。 + +| 壊し方 | 結果 | +| -------------------------------------------------------------- | ------------------------------------------ | +| `src/val.ts` のトップレベルで `equals` を参照し、core に漏らす | `AddsNothingError` | +| `define` を効かなくし、両方のモードを同じコードにする | `DevCodeKeptError` | +| `development` を `true` に固定する | 検査は通る。ソースが `NODE_ENV` を読まない | + +**拾えないもの: development 用の分岐の一部だけが production に残る場合。**`development` で囲み忘れた +検査が 1 つあっても、ほかの分岐は消えるのでサイズは違ったまま。予算が拾うのは、core の余りより大きい漏れだけ。 +`development` を `true` に固定したときは、core が 896 B から 1.65 kB になり、予算を 670 B 超えた。 + +**却下: 検査をやめて予算に任せる。**core の余り 128 B より大きい漏れは予算が拾う。実測では development 用の +コードが約 +790 B、`equals` が +187 B。ただし `equals` は余りより 59 B 大きいだけで、core が増えないまま +core の予算を 60 B 上げれば通る。サイズの比較は余りに左右されない。 + +**名前は検査の範囲に合わせる、2026-10-16。**`BranchIgnoredError` を `DevCodeKeptError` に改めた。前の名前は、 +何の分岐を何が無視したのかを言っていなかった。 + +- **却下: `TreeShakingError`。**tree-shaking は使われない export やモジュールを落とすこと。`if` の分岐を消すのは + dead code elimination で、別の工程 +- **却下: `DeadCodeEliminationError`。**この検査が見るのは development 用の分岐だけで、dead code 全般ではない。 + 名前が検査の保証より広い + --- ## 17. Effect v4 調査、2026-09-10 diff --git a/package.json b/package.json index a1f6553..ce0bbc4 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,9 @@ ], "type": "module", "sideEffects": false, + "imports": { + "#valof": "valof-release" + }, "exports": { ".": "./dist/index.mjs", "./eslint-plugin": "./dist/eslint-plugin.mjs", @@ -48,7 +51,7 @@ "docs:serve": "mdbook serve docs", "test": "vp test", "check": "vp check", - "bundle-size": "node scripts/bundle-size.ts", + "bundle-size": "node scripts/bundle-size/index.ts", "replace-bundle-size-claims": "node scripts/replace-bundle-size-claims.ts", "ts-compatibility": "node scripts/ts-compatibility/index.ts", "type-perf": "node scripts/type-perf/index.ts", @@ -58,6 +61,7 @@ }, "devDependencies": { "@types/node": "^26.5.0", + "better-result": "^3.0.1", "bumpp": "^11.1.0", "eslint": "^10.10.0", "handlebars": "^4.7.9", @@ -69,6 +73,7 @@ "typescript": "^7.0.2", "typescript-5": "npm:typescript@^5.9.3", "typescript-6": "npm:typescript@^6.0.3", + "valof-release": "npm:valof@0.9.0", "vite": "catalog:", "vite-plus": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7393998..1f01f43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: '@types/node': specifier: ^26.5.0 version: 26.5.0 + better-result: + specifier: ^3.0.1 + version: 3.0.1 bumpp: specifier: ^11.1.0 version: 11.1.0 @@ -252,6 +255,9 @@ importers: typescript-6: specifier: npm:typescript@^6.0.3 version: typescript@6.0.3 + valof-release: + specifier: npm:valof@0.9.0 + version: valof@0.9.0(oxc-parser@0.148.0) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.9 version: '@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.5.0)(jiti@2.7.0)(typescript@7.0.2)(yaml@2.9.0)' @@ -1398,6 +1404,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + better-result@3.0.1: + resolution: {integrity: sha512-rnCeqEXkKPHmz5MO5ZroPRDyHe4YvlNPyy7qj8cVcWs/GnvSvC3086G74/220W+wWcIWlXgzvcjskFV0A4blUQ==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -2085,6 +2094,15 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + valof@0.9.0: + resolution: {integrity: sha512-dK8txjbRViHXFBw9OOIGd/NRFRW/qGOJwo61FOvW6RlgdQXyfpKDECEacj1aQAxQhwzTLgrcDcV4p/FXJQ0TaQ==} + hasBin: true + peerDependencies: + oxc-parser: '>=0.148' + peerDependenciesMeta: + oxc-parser: + optional: true + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -2907,6 +2925,8 @@ snapshots: balanced-match@4.0.4: {} + better-result@3.0.1: {} + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -3732,6 +3752,10 @@ snapshots: dependencies: punycode: 2.3.1 + valof@0.9.0(oxc-parser@0.148.0): + optionalDependencies: + oxc-parser: 0.148.0 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/scripts/bundle-size.ts b/scripts/bundle-size.ts deleted file mode 100644 index 58b0c4b..0000000 --- a/scripts/bundle-size.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { gzipSync, brotliCompressSync, constants } from "node:zlib"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { minifySync, build, type Rolldown } from "vite"; - -const root = new URL("../", import.meta.url); - -// `core` is the budget everyone pays. The other entries measure what `Trait`, `equals`, or both -// add on top. `Trait` ships from its own subpath, so the entry names the module per import. -const entries = { - core: { "./dist/index.mjs": ["Val"] }, - trait: { "./dist/index.mjs": ["Val"], "./dist/experimental.mjs": ["Trait"] }, - enum: { "./dist/index.mjs": ["Val"], "./dist/experimental.mjs": ["Enum"] }, - equals: { "./dist/index.mjs": ["Val", "equals"] }, - all: { "./dist/index.mjs": ["Val", "equals"], "./dist/experimental.mjs": ["Enum", "Trait"] }, -} as const satisfies Record>>; -type Entry = keyof typeof entries; -type Imports = Readonly>; - -/** What the entry imports, as the printed heading spells it. */ -const imported = (imports: Imports): string[] => Object.values(imports).flat(); - -const modes = ["production", "development"] as const; - -/** - * What a user ships. A ratchet, unlike the budgets in `type-perf`: runtime code is written - * deliberately, so a byte more is a decision, not drift. - * - * The declarations are not measured here. They cost a download and a parse, never a byte in the - * user's bundle, and `type-perf` already measures what reads them. - * - * `traitGzip` and `enumGzip` are the first budget plus what each adds, so the pair says the line - * rather than a second round number: what an experimental import costs is the number to read. - */ -const BUDGET_VAL = 1024; -const BUDGET_VAL_PLUS_EQUALS = BUDGET_VAL + 128; -const BUDGET_VAL_PLUS_TRAIT = BUDGET_VAL + 256; -const BUDGET_VAL_PLUS_ENUM = BUDGET_VAL + 384; -const BUDGET_ALL = BUDGET_VAL + 640; -const budget = { - val: BUDGET_VAL, - equals: BUDGET_VAL_PLUS_EQUALS, - traitGzip: BUDGET_VAL_PLUS_TRAIT, - enumGzip: BUDGET_VAL_PLUS_ENUM, - all: BUDGET_ALL, -}; - -type Sizes = { minified: number; gzip: number; brotli: number }; - -async function bundle(mode: string, imports: Imports): Promise { - const dir = await mkdtemp(join(tmpdir(), "valof-size-")); - const file = join(dir, "entry.mjs"); - const lines = Object.entries(imports).map( - ([module, names]) => - `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, - ); - await writeFile(file, `${lines.join("\n")}\nconsole.log(${imported(imports).join(", ")});\n`); - try { - const result = (await build({ - root: fileURLToPath(root), - logLevel: "silent", - configFile: false, - define: { "process.env.NODE_ENV": JSON.stringify(mode) }, - build: { - write: false, - minify: false, - target: "esnext", - lib: { entry: file, formats: ["es"], fileName: "entry" }, - }, - })) as Rolldown.RolldownOutput[]; - const chunk = result[0]?.output.find((output) => output.type === "chunk"); - if (chunk?.type !== "chunk") throw new Error(`no chunk emitted for ${mode}`); - return chunk.code; - } finally { - await rm(dir, { recursive: true, force: true }); - } -} - -function measure(code: string): Sizes { - const bytes = Buffer.from(minifySync("bundle.mjs", code).code, "utf8"); - return { - minified: bytes.byteLength, - gzip: gzipSync(bytes, { level: 9 }).byteLength, - brotli: brotliCompressSync(bytes, { - params: { - [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, - [constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, - }, - }).byteLength, - }; -} - -function format(bytes: number): string { - return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(2)} kB`; -} - -function table(rows: [string, Record][]): string { - const heads = Object.keys(rows[0]?.[1] ?? {}); - const label = Math.max(...rows.map(([name]) => name.length)); - const widths = heads.map((head, index) => - Math.max(head.length, ...rows.map(([, cells]) => format(Object.values(cells)[index]!).length)), - ); - const line = (name: string, cells: string[]) => - ` ${name.padEnd(label)} ${cells.map((cell, index) => cell.padStart(widths[index]!)).join(" ")}`; - return [ - line("", heads), - ...rows.map(([name, cells]) => line(name, Object.values(cells).map(format))), - ].join("\n"); -} - -await promisify(execFile)(fileURLToPath(new URL("node_modules/.bin/vp", root)), ["pack"], { - cwd: fileURLToPath(root), -}).catch((error: { stdout?: string; stderr?: string }) => { - console.error(error.stdout ?? ""); - console.error(error.stderr ?? ""); - process.exit(1); -}); - -const names = Object.keys(entries) as Entry[]; -const code = Object.fromEntries( - await Promise.all( - names.map(async (name) => [ - name, - Object.fromEntries( - await Promise.all(modes.map(async (mode) => [mode, await bundle(mode, entries[name])])), - ) as Record<(typeof modes)[number], string>, - ]), - ), -) as Record>; - -const bundles = Object.fromEntries( - names.map((name) => [ - name, - Object.fromEntries(modes.map((mode) => [mode, measure(code[name][mode])])), - ]), -) as Record>; - -const production = minifySync("production.mjs", code.core.production).code; -if ( - production.includes("snapshotSealInput") || - production.includes("unsnapshotable seal input") || - production.includes("The payload changed while the custom seal was running.") -) { - throw new Error("development-only seal stability checks remain in the production bundle"); -} -if (production.includes("Number.isNaN")) { - throw new Error("equals remains in the production bundle that imports only Val"); -} - -const measured = { - val: bundles.core, - trait: bundles.trait, - enum: bundles.enum, - equals: bundles.equals, - all: bundles.all, -}; - -const checks = [ - ["production gzip", measured.val.production.gzip, budget.val], - ["production gzip, with Equals", measured.equals.production.gzip, budget.equals], - ["production gzip, with Trait", measured.trait.production.gzip, budget.traitGzip], - ["production gzip, with Enum", measured.enum.production.gzip, budget.enumGzip], - ["production gzip, all", measured.all.production.gzip, budget.all], -] as const; - -function budgets(): string { - const label = Math.max(...checks.map(([name]) => name.length)); - const used = Math.max(...checks.map(([, size]) => format(size).length)); - const limit = Math.max(...checks.map(([, , max]) => format(max).length)); - return checks - .map(([name, size, max]) => { - const left = - size > max - ? `${format(size - max)} over` - : `${format(max - size)} left (${Math.round((1 - size / max) * 100)}%)`; - return ` ${name.padEnd(label)} ${format(size).padStart(used)} / ${format(max).padStart(limit)} ${left}`; - }) - .join("\n"); -} - -if (process.argv.includes("--json")) { - console.log(JSON.stringify(measured, null, 2)); -} else { - for (const name of names) { - console.log(`bundle import { ${imported(entries[name]).join(", ")} }`); - console.log(table(modes.map((mode) => [mode, bundles[name][mode]]))); - console.log(); - } - console.log("budget"); - console.log(budgets()); -} - -const over = checks.filter(([, bytes, max]) => bytes > max); - -if (over.length > 0) { - console.error(); - for (const [name, bytes, max] of over) { - console.error(`over budget: ${name} is ${format(bytes)}, budget ${format(max)}`); - } - process.exit(1); -} diff --git a/scripts/bundle-size/bundle.ts b/scripts/bundle-size/bundle.ts new file mode 100644 index 0000000..8ce1795 --- /dev/null +++ b/scripts/bundle-size/bundle.ts @@ -0,0 +1,30 @@ +import { Val } from "#valof"; + +export type Imports = Val<"Imports", Record>; +export const Imports = Val.sealer().impl({ + names: (imports): string[] => Object.values(imports).flat(), + merge: (imports, other: Imports): Imports => { + const merged: Record = {}; + for (const source of [imports, other]) { + for (const [module, names] of Object.entries(source)) { + merged[module] = [...new Set([...(merged[module] ?? []), ...names])]; + } + } + + return Imports(merged); + }, +}); + +export type Bundle = Val< + "Bundle", + { + /** The key in the `--json` output, which `replace-bundle-size-claims` reads. */ + key: string; + name: string; + /** A base bundle is capped by its whole size, any other by what it adds to the base. */ + base?: boolean; + imports: Imports; + budget: number; + } +>; +export const Bundle = Val.sealer(); diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts new file mode 100644 index 0000000..e8d734c --- /dev/null +++ b/scripts/bundle-size/cli.ts @@ -0,0 +1,30 @@ +import { Result as R } from "better-result"; +import { over, render } from "./format.ts"; +import { BundleSize } from "./measure/index.ts"; +import { Suite } from "./suite.ts"; +import { flow } from "../shared/pipe/index.ts"; + +export const Cli = { + run: async (suite: Suite): Promise => { + process.exitCode = await flow( + suite, + Suite.measure, + R.match({ + ok: (report) => { + console.log(render(report, process.argv.includes("--json"))); + + const overs = report.bundles.filter(BundleSize.over).map(over); + for (const line of overs.length > 0 ? ["", ...overs] : []) { + console.error(line); + } + + return overs.length > 0 ? 1 : 0; + }, + err: (error) => { + console.error(error.message); + return 1; + }, + }), + ); + }, +}; diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts new file mode 100644 index 0000000..b0a5ee8 --- /dev/null +++ b/scripts/bundle-size/format.ts @@ -0,0 +1,84 @@ +import { Imports } from "./bundle.ts"; +import { modes, type BuildResult, type BundleSize } from "./measure/index.ts"; +import type { Report } from "./suite.ts"; +import { pipe } from "../shared/pipe/index.ts"; + +export function over(size: BundleSize): string { + return `over budget: ${label(size)} is ${amount(size, size.capped)}, budget ${amount(size, size.bundle.budget)}`; +} + +export function render({ bundles, separateBase }: Report, asJson: boolean): string { + return asJson + ? json(bundles) + : [ + ...[...separateBase, ...bundles.map(({ build }) => build)].map( + (each) => `${sizes(each)}\n`, + ), + budgets(bundles), + ].join("\n"); +} + +function format(bytes: number): string { + return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(2)} kB`; +} + +function align(rows: readonly (readonly string[])[], sides: readonly ("left" | "right")[]) { + const columns = sides.map((side, index) => { + const cells = rows.map((row) => row[index] ?? ""); + const width = Math.max(...cells.map((cell) => cell.length)); + return cells.map((cell) => (side === "left" ? cell.padEnd(width) : cell.padStart(width))); + }); + return rows.map((_, index) => columns.map((cells) => cells[index] ?? "")); +} + +function amount({ bundle }: BundleSize, bytes: number): string { + return bundle.base ? format(bytes) : `+${format(bytes)}`; +} + +function label({ bundle }: BundleSize): string { + return bundle.base ? bundle.name : `added by ${bundle.name}`; +} + +function sizes(build: BuildResult): string { + const heads = ["minified", "gzip", "brotli"] as const; + return pipe( + [ + ["", ...heads], + ...modes.map((mode) => [mode, ...heads.map((head) => format(build[mode].sizes[head]))]), + ], + (rows) => align(rows, ["left", "right", "right", "right"]), + (rows) => rows.map((cells) => ` ${cells.join(" ")}`), + (lines) => [`bundle import { ${Imports.names(build.imports).join(", ")} }`, ...lines], + (lines) => lines.join("\n"), + ); +} + +function budgets(bundles: readonly BundleSize[]): string { + return pipe( + bundles.map((size) => { + const bytes = size.capped; + const max = size.bundle.budget; + const left = + bytes > max + ? `${format(bytes - max)} over` + : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; + return [label(size), amount(size, bytes), amount(size, max), left]; + }), + (rows) => align(rows, ["left", "right", "right", "left"]), + (rows) => + rows.map(([name, used, limit, left]) => ` ${name} ${used} / ${limit} ${left}`.trimEnd()), + (lines) => ["budget (production gzip)", ...lines], + (lines) => lines.join("\n"), + ); +} + +function json(bundles: readonly BundleSize[]): string { + return pipe( + bundles.map(({ bundle, build }) => [ + bundle.key, + Object.fromEntries(modes.map((mode) => [mode, build[mode].sizes])), + ]), + (entries) => Object.fromEntries(entries), + (keyed) => JSON.stringify(keyed, null, 2), + ); +} diff --git a/scripts/bundle-size/index.ts b/scripts/bundle-size/index.ts new file mode 100644 index 0000000..4c090fa --- /dev/null +++ b/scripts/bundle-size/index.ts @@ -0,0 +1,42 @@ +import { Bundle, Imports } from "./bundle.ts"; +import { Cli } from "./cli.ts"; +import { Suite } from "./suite.ts"; + +const suite = Suite([ + Bundle({ + key: "val", + name: "Val", + base: true, + imports: Imports({ "./dist/index.mjs": ["Val"] }), + budget: 1024, + }), + Bundle({ + key: "equals", + name: "Equals", + imports: Imports({ "./dist/index.mjs": ["equals"] }), + budget: 256, + }), + Bundle({ + key: "trait", + name: "Trait", + imports: Imports({ "./dist/experimental.mjs": ["Trait"] }), + budget: 256, + }), + Bundle({ + key: "enum", + name: "Enum", + imports: Imports({ "./dist/experimental.mjs": ["Enum"] }), + budget: 640, + }), + Bundle({ + key: "all", + name: "All", + imports: Imports({ + "./dist/index.mjs": ["equals"], + "./dist/experimental.mjs": ["Enum", "Trait"], + }), + budget: 1024, + }), +]); + +await Cli.run(suite); diff --git a/scripts/bundle-size/measure/index.ts b/scripts/bundle-size/measure/index.ts new file mode 100644 index 0000000..2279e8f --- /dev/null +++ b/scripts/bundle-size/measure/index.ts @@ -0,0 +1,80 @@ +import { Result as R, TaggedError } from "better-result"; +import { Val } from "#valof"; +import { Imports, type Bundle } from "../bundle.ts"; + +export class DevCodeKeptError extends TaggedError("DevCodeKeptError")<{ + imports: Imports; + message: string; +}> { + constructor({ imports }: { imports: Imports }) { + super({ + imports, + message: `production keeps the development branches: import { ${Imports.names(imports).join(", ")} }`, + }); + } +} + +export class AddsNothingError extends TaggedError("AddsNothingError")<{ + bundle: Bundle; + message: string; +}> { + constructor({ bundle }: { bundle: Bundle }) { + super({ bundle, message: `${bundle.name} adds nothing to the base, which already holds it` }); + } +} + +export type Mode = "production" | "development"; +export const modes: readonly Mode[] = ["production", "development"]; + +export type Sizes = Val<"Sizes", { minified: number; gzip: number; brotli: number }>; +export const Sizes = Val.sealer(); + +export type Build = Val< + "Build", + { + sizes: Sizes; + /** The code's length, leaving out the entry that imports the library. */ + library: number; + } +>; +export const Build = Val.sealer(); + +export type BuildResult = Val< + "BuildResult", + { + imports: Imports; + readsNodeEnv: boolean; + production: Build; + development: Build; + } +>; +export const BuildResult = Val.sealer().impl({ + check: (build): R => + !build.readsNodeEnv || build.production.sizes.minified !== build.development.sizes.minified + ? R.ok() + : R.err(new DevCodeKeptError({ imports: build.imports })), +}); + +export type BundleSize = Val< + "BundleSize", + { + bundle: Bundle; + build: BuildResult; + capped: number; + } +>; +export const BundleSize = Val.companion() + .implCreate((bundle: Bundle, build: BuildResult, base: BuildResult) => ({ + bundle, + build, + capped: bundle.base + ? build.production.sizes.gzip + : build.production.sizes.gzip - base.production.sizes.gzip, + })) + .impl({ + over: (size): boolean => size.capped > size.bundle.budget, + check: (size, base: BuildResult): R => + size.bundle.base || size.build.production.library !== base.production.library + ? R.ok() + : R.err(new AddsNothingError({ bundle: size.bundle })), + }); diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts new file mode 100644 index 0000000..28afcf5 --- /dev/null +++ b/scripts/bundle-size/measure/toolchain.ts @@ -0,0 +1,207 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { gzipSync, brotliCompressSync, constants } from "node:zlib"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Result as R, TaggedError } from "better-result"; +import { minifySync, build, type Rolldown } from "vite"; +import { Val } from "#valof"; +import { Imports } from "../bundle.ts"; +import { flow, pipe } from "../../shared/pipe/index.ts"; +import { GenAwait } from "../../shared/util.ts"; +import { Build, BuildResult, Sizes, type Mode } from "./index.ts"; + +export class PackError extends TaggedError("PackError")<{ output: string; message: string }> { + constructor({ output }: { output: string }) { + super({ output, message: `vp pack failed\n${output}` }); + } +} + +export class NoChunkError extends TaggedError("NoChunkError")<{ mode: Mode; message: string }> { + constructor({ mode }: { mode: Mode }) { + super({ mode, message: `no chunk emitted for ${mode}` }); + } +} + +export class CompileError extends TaggedError("CompileError")<{ + mode: Mode; + step: string; + cause: unknown; + message: string; +}> { + constructor({ mode, step, cause }: { mode: Mode; step: string; cause: unknown }) { + super({ mode, step, cause, message: `${step} failed for ${mode}: ${String(cause)}` }); + } +} + +export class MinifyError extends TaggedError("MinifyError")<{ + cause: unknown; + message: string; +}> { + constructor({ cause }: { cause: unknown }) { + super({ cause, message: `minify failed: ${String(cause)}` }); + } +} + +const root = new URL("../../../", import.meta.url); + +export function pack(): Promise> { + return R.tryPromise({ + try: async () => { + await promisify(execFile)(fileURLToPath(new URL("node_modules/.bin/vp", root)), ["pack"], { + cwd: fileURLToPath(root), + }); + }, + catch: (cause) => { + const { stdout = "", stderr = "" } = cause as { stdout?: string; stderr?: string }; + return new PackError({ output: `${stdout}${stderr}` || String(cause) }); + }, + }); +} + +export function measure( + imports: Imports, +): Promise> { + return R.gen(async function* () { + const [production, development] = yield* flow( + [compile("production", imports), compile("development", imports)] as const, + R.allAsync, + GenAwait, + ); + + const readsNodeEnv = yield* flow( + development.modules.map((module) => Module.readsNodeEnv(module, "development")), + R.allAsync, + R.map((reads) => reads.some((read) => read)), + GenAwait, + ); + + return R.ok( + BuildResult({ + imports, + readsNodeEnv, + production: yield* Compiled.measure(production), + development: yield* Compiled.measure(development), + }), + ); + }); +} + +type Code = Val<"Code", string>; +const Code = Val.sealer().impl({ + minify: (code): R => + R.try({ + try: () => Code(minifySync("bundle.mjs", code).code), + catch: (cause) => new MinifyError({ cause }), + }), + sizes: (code): R => + Code.minify(code).map((minified) => { + const bytes = Buffer.from(minified, "utf8"); + return Sizes({ + minified: bytes.byteLength, + gzip: gzipSync(bytes, { level: 9 }).byteLength, + brotli: brotliCompressSync(bytes, { + params: { + [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, + [constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, + }, + }).byteLength, + }); + }), +}); + +type Module = Val<"Module", string>; +const Module = Val.sealer().impl({ + /** Whether the module's source reads `NODE_ENV`, before `define` replaces it. */ + readsNodeEnv: (module, mode: Mode): Promise> => + attempt(mode, "reading the library", async () => + (await readFile(module, "utf8")).includes("NODE_ENV"), + ), +}); + +type Compiled = Val<"Compiled", { code: Code; modules: Module[]; library: number }>; +const Compiled = Val.sealer().impl({ + measure: (compiled): R => + Code.sizes(compiled.code).map((sizes) => Build({ sizes, library: compiled.library })), +}); + +function compile(mode: Mode, imports: Imports): Promise> { + return R.gen(async function* () { + const dir = yield* R.await( + attempt(mode, "creating the entry directory", () => mkdtemp(join(tmpdir(), "valof-size-"))), + ); + + const file = join(dir, "entry.mjs"); + + const built = await R.gen(async function* () { + yield* R.await( + attempt(mode, "writing the entry", () => { + const content = pipe( + Object.entries(imports).map( + ([module, names]) => + `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, + ), + (lines) => [...lines, `console.log(${Imports.names(imports).join(", ")});`, ""], + (lines) => lines.join("\n"), + ); + return writeFile(file, content); + }), + ); + + const chunk = yield* flow( + attempt( + mode, + "the build", + () => + build({ + root: fileURLToPath(root), + logLevel: "silent", + configFile: false, + define: { "process.env.NODE_ENV": JSON.stringify(mode) }, + build: { + write: false, + minify: false, + target: "esnext", + lib: { entry: file, formats: ["es"], fileName: "entry" }, + }, + }) as Promise, + ), + R.map((result) => result[0]?.output.find((output) => output.type === "chunk")), + R.andThen((found) => + found?.type === "chunk" ? R.ok(found) : R.err(new NoChunkError({ mode })), + ), + GenAwait, + ); + + const entry = chunk.facadeModuleId === null ? undefined : chunk.modules[chunk.facadeModuleId]; + + return R.ok( + Compiled({ + code: Code(chunk.code), + modules: chunk.moduleIds + .filter((id) => id !== chunk.facadeModuleId) + .map((id) => Module(id)), + // A module's own `renderedLength` counts code that tree-shaking drops, so the library is + // what the chunk holds beyond its entry. + library: chunk.code.length - (entry?.renderedLength ?? 0), + }), + ); + }); + + // Removed whether or not the build succeeded. + yield* R.await( + attempt(mode, "removing the entry directory", () => + rm(dir, { recursive: true, force: true }), + ), + ); + + return built; + }); +} + +/** Runs one step of building a bundle, turning a rejection into a {@link CompileError}. */ +function attempt(mode: Mode, step: string, run: () => Promise): Promise> { + return R.tryPromise({ try: run, catch: (cause) => new CompileError({ mode, step, cause }) }); +} diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts new file mode 100644 index 0000000..171bcd0 --- /dev/null +++ b/scripts/bundle-size/suite.ts @@ -0,0 +1,96 @@ +import { Result as R, TaggedError } from "better-result"; +import { Val } from "#valof"; +import { Imports, type Bundle } from "./bundle.ts"; +import { flow } from "../shared/pipe/index.ts"; +import { GenAwait } from "../shared/util.ts"; +import { + BuildResult, + BundleSize, + type AddsNothingError, + type DevCodeKeptError, +} from "./measure/index.ts"; +import { + measure, + pack, + type CompileError, + type MinifyError, + type NoChunkError, + type PackError, +} from "./measure/toolchain.ts"; + +export class NoBaseError extends TaggedError("NoBaseError")<{ message: string }> { + constructor() { + super({ message: "no bundle sets base" }); + } +} + +export type Report = Val< + "Report", + { + base: BuildResult; + bundles: BundleSize[]; + separateBase: BuildResult[]; + } +>; +export const Report = Val.sealer(); + +export type Suite = Val<"Suite", Bundle[]>; +export const Suite = Val.sealer().impl({ + base: (suite): R => { + const [first, ...others] = suite + .filter((bundle) => bundle.base) + .map((bundle) => bundle.imports); + return first !== undefined + ? R.ok(others.reduce((merged, imports) => Imports.merge(merged, imports), first)) + : R.err(new NoBaseError()); + }, + measure: ( + suite: Suite, + ): Promise< + R< + Report, + | PackError + | NoBaseError + | NoChunkError + | CompileError + | MinifyError + | DevCodeKeptError + | AddsNothingError + > + > => + R.gen(async function* () { + yield* R.await(pack()); + + const baseImports = yield* Suite.base(suite); + const results = yield* flow( + suite.map((bundle) => + flow( + bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports), + measure, + R.map((build) => ({ bundle, build })), + ), + ), + R.allAsync, + GenAwait, + ); + + const [only, ...more] = results.filter(({ bundle }) => bundle.base); + const base = + only !== undefined && more.length === 0 + ? only.build + : yield* flow(baseImports, measure, GenAwait); + const separateBase = base === only?.build ? [] : [base]; + + for (const build of [...separateBase, ...results.map(({ build }) => build)]) { + yield* BuildResult.check(build); + } + + const bundles = results.map(({ bundle, build }) => BundleSize.create(bundle, build, base)); + + for (const size of bundles) { + yield* BundleSize.check(size, base); + } + + return R.ok(Report({ base, bundles, separateBase })); + }), +}); diff --git a/scripts/replace-bundle-size-claims.ts b/scripts/replace-bundle-size-claims.ts index 9c8ca0b..edf5ac9 100644 --- a/scripts/replace-bundle-size-claims.ts +++ b/scripts/replace-bundle-size-claims.ts @@ -41,7 +41,7 @@ let stdout: string; try { ({ stdout } = await promisify(execFile)( process.execPath, - [fileURLToPath(new URL("bundle-size.ts", import.meta.url)), "--json"], + [fileURLToPath(new URL("bundle-size/index.ts", import.meta.url)), "--json"], { cwd: fileURLToPath(root) }, )); } catch (error) { diff --git a/scripts/shared/pipe/generate-tests.ts b/scripts/shared/pipe/generate-tests.ts new file mode 100644 index 0000000..73e2088 --- /dev/null +++ b/scripts/shared/pipe/generate-tests.ts @@ -0,0 +1,107 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +/** The most steps `pipe` and `flow` take, as their overloads in `index.ts` declare. */ +const steps = 20; + +const root = new URL("../../../", import.meta.url); +const header = + "// Generated by generate-tests.ts. Run `node scripts/shared/pipe/generate-tests.ts` to update."; + +function nested(depth: number): string { + return `number${"[]".repeat(depth)}`; +} + +// The type error lands on the first argument no overload can take, so the directive goes there. +function tooMany(call: string, last = "wrap"): string { + return [ + ` ${call}(`, + " 0,", + ...Array(last === "wrap" ? steps : steps + 1).fill(" wrap,"), + " // @ts-expect-error no overload takes this many steps", + ` ${last},`, + " );", + ].join("\n"); +} + +// The last step is async, so an overload whose result skips `Awaited` fails. +function chain(n: number): string { + return lengths + .slice(0, n) + .map((i) => ((n - i) % 2 === 0 ? "wrapAsync" : "wrap")) + .join(", "); +} + +const lengths = Array.from({ length: steps }, (_, i) => i + 1); + +// Every step changes the type, so an overload that wires a step to the wrong one fails. +const tests = `${header} +import { describe, expectTypeOf, test } from "vite-plus/test"; + +import { flow, pipe, type Sink } from "./index.ts"; + +const wrap = (x: T): T[] => [x]; +const wrapAsync = async (x: T): Promise => [x]; + +type Held

= { promise: P }; +interface Holder extends Sink { + readonly out: Held; +} +const holder = { sink: (promise: unknown) => ({ promise }) } as unknown as Holder; + +describe("pipe", () => { + test("chains each step to the one before, at every length", () => { +${lengths + .map( + (n) => + ` expectTypeOf(pipe(0, ${Array(n).fill("wrap").join(", ")})).toEqualTypeOf<${nested(n)}>();`, + ) + .join("\n")} +${tooMany("pipe")} + }); +}); + +describe("flow", () => { + test("chains each step to the resolved one before, at every length", () => { +${lengths + .map((n) => ` expectTypeOf(flow(0, ${chain(n)})).toEqualTypeOf>();`) + .join("\n")} +${tooMany("void flow")} + }); + + test("hands the chain's promise to a sink, at every length", () => { +${lengths + .map( + (n) => + ` expectTypeOf(flow(0, ${chain(n)}, holder)).toEqualTypeOf<{ promise: Promise<${nested(n)}> }>();`, + ) + .join("\n")} +${tooMany("flow", "holder")} + }); +}); +`; + +function format(file: URL, text: string): Promise { + return new Promise((resolve, reject) => { + const child = execFile( + fileURLToPath(new URL("node_modules/.bin/vp", root)), + ["fmt", `--stdin-filepath=${fileURLToPath(file)}`], + { cwd: fileURLToPath(root) }, + (error, stdout) => (error ? reject(error) : resolve(stdout)), + ); + child.stdin?.end(text); + }); +} + +/** Where {@link generate} writes. */ +const output = new URL("generated.test.ts", import.meta.url); + +/** The type tests, one per overload, formatted. */ +export function generate(): Promise { + return format(output, tests); +} + +if (import.meta.main) { + await writeFile(output, await generate()); +} diff --git a/scripts/shared/pipe/generated.test.ts b/scripts/shared/pipe/generated.test.ts new file mode 100644 index 0000000..7789aee --- /dev/null +++ b/scripts/shared/pipe/generated.test.ts @@ -0,0 +1,764 @@ +// Generated by generate-tests.ts. Run `node scripts/shared/pipe/generate-tests.ts` to update. +import { describe, expectTypeOf, test } from "vite-plus/test"; + +import { flow, pipe, type Sink } from "./index.ts"; + +const wrap = (x: T): T[] => [x]; +const wrapAsync = async (x: T): Promise => [x]; + +type Held

= { promise: P }; +interface Holder extends Sink { + readonly out: Held; +} +const holder = { sink: (promise: unknown) => ({ promise }) } as unknown as Holder; + +describe("pipe", () => { + test("chains each step to the one before, at every length", () => { + expectTypeOf(pipe(0, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf< + number[][][][][][][] + >(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf< + number[][][][][][][][] + >(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf< + number[][][][][][][][][] + >(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf< + number[][][][][][][][][][] + >(); + expectTypeOf( + pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap), + ).toEqualTypeOf(); + expectTypeOf( + pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap), + ).toEqualTypeOf(); + expectTypeOf( + pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap), + ).toEqualTypeOf(); + expectTypeOf( + pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + // @ts-expect-error no overload takes this many steps + wrap, + ); + }); +}); + +describe("flow", () => { + test("chains each step to the resolved one before, at every length", () => { + expectTypeOf(flow(0, wrapAsync)).toEqualTypeOf>(); + expectTypeOf(flow(0, wrap, wrapAsync)).toEqualTypeOf>(); + expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync)).toEqualTypeOf>(); + expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync)).toEqualTypeOf< + Promise + >(); + expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync)).toEqualTypeOf< + Promise + >(); + expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync)).toEqualTypeOf< + Promise + >(); + expectTypeOf( + flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync), + ).toEqualTypeOf>(); + expectTypeOf( + flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync), + ).toEqualTypeOf>(); + expectTypeOf( + flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync), + ).toEqualTypeOf>(); + expectTypeOf( + flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + void flow( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + // @ts-expect-error no overload takes this many steps + wrap, + ); + }); + + test("hands the chain's promise to a sink, at every length", () => { + expectTypeOf(flow(0, wrapAsync, holder)).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf(flow(0, wrap, wrapAsync, holder)).toEqualTypeOf<{ + promise: Promise; + }>(); + expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync, holder)).toEqualTypeOf<{ + promise: Promise; + }>(); + expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync, holder)).toEqualTypeOf<{ + promise: Promise; + }>(); + expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, holder)).toEqualTypeOf<{ + promise: Promise; + }>(); + expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, holder)).toEqualTypeOf<{ + promise: Promise; + }>(); + expectTypeOf( + flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, holder), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, holder), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + flow( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + // @ts-expect-error no overload takes this many steps + holder, + ); + }); +}); diff --git a/scripts/shared/pipe/index.test.ts b/scripts/shared/pipe/index.test.ts new file mode 100644 index 0000000..2fdf352 --- /dev/null +++ b/scripts/shared/pipe/index.test.ts @@ -0,0 +1,170 @@ +import { Result as R } from "better-result"; +import { readFile } from "node:fs/promises"; +import { describe, expect, expectTypeOf, test, vi } from "vite-plus/test"; + +import { generate } from "./generate-tests.ts"; +import { flow, pipe, tap, type Sink } from "./index.ts"; +import { GenAwait } from "../util.ts"; + +class NotFoundError extends Error {} +class InvalidError extends Error {} + +describe("pipe", () => { + test("infers each step's argument from the previous step", () => { + const length = pipe( + R.ok("abc"), + R.map((s) => s.length), + R.andThen((n) => (n > 0 ? R.ok(n) : R.err(new InvalidError()))), + ); + expectTypeOf(length).toEqualTypeOf>(); + expect(length.unwrap()).toBe(3); + }); +}); + +describe("flow", () => { + test("rejects with a step's rejection and skips the steps after it", async () => { + const error = new Error("failed"); + const rest = vi.fn(); + await expect(flow("a", () => Promise.reject(error), rest)).rejects.toBe(error); + expect(rest).not.toHaveBeenCalled(); + }); + + test("awaits the initial value", async () => { + expect(await flow(Promise.resolve("a"), (s) => `${s}b`)).toBe("ab"); + }); + + test("infers each step's argument from the resolved previous step", async () => { + const length = flow( + R.ok("abc"), + R.map((s) => s.length), + R.andThenAsync(async (n) => (n > 0 ? R.ok(n) : R.err(new InvalidError()))), + R.map((n) => n * 2), + ); + expectTypeOf(length).toEqualTypeOf>>(); + expect((await length).unwrap()).toBe(6); + }); + + test("hands the chain's promise, not its value, to a sink", async () => { + const seen = vi.fn(); + const handed = flow(1, (n) => n + 1, { + sink: (promise: unknown) => { + seen(promise); + return "handed"; + }, + } as unknown as Sink); + expect(handed).toBe("handed"); + const [promise] = seen.mock.calls[0] ?? []; + expect(promise).toBeInstanceOf(Promise); + expect(await promise).toBe(2); + }); +}); + +describe("GenAwait", () => { + test("gives a Result generator the Ok value as flow's last step", async () => { + const result = await R.gen(async function* () { + const n = yield* flow( + R.ok(1), + R.map((n) => n + 1), + GenAwait, + ); + expectTypeOf(n).toEqualTypeOf(); + return R.ok(n * 10); + }); + expectTypeOf(result).toEqualTypeOf>(); + expect(result.unwrap()).toBe(20); + }); + + test("stops a Result generator at an Err as flow's last step", async () => { + const error = new NotFoundError(); + const after = vi.fn(); + const result = await R.gen(async function* () { + yield* flow( + R.err(error), + R.map((n) => n + 1), + GenAwait, + ); + after(); + return R.ok(0); + }); + expect(result.isErr() && result.error).toBe(error); + expect(after).not.toHaveBeenCalled(); + }); +}); + +describe("tap", () => { + test("calls the function with the value and passes the value on", () => { + const seen = vi.fn(); + const result = pipe( + 1, + tap((n) => { + seen(n.toFixed(1)); + }), + (n) => n + 1, + ); + expectTypeOf(result).toEqualTypeOf(); + expect(seen).toHaveBeenCalledWith("1.0"); + expect(result).toBe(2); + }); + + test("awaits the promise the function returns before the next step", async () => { + const order: string[] = []; + const tapped = pipe( + 1, + tap( + () => + new Promise((resolve) => + setTimeout(() => { + order.push("tap"); + resolve(); + }), + ), + ), + ); + expectTypeOf(tapped).toEqualTypeOf>(); + expect( + await flow(tapped, (n) => { + order.push("next"); + return n + 1; + }), + ).toBe(2); + expect(order).toEqual(["tap", "next"]); + }); + + test("types the step as a value or a promise when the function's result is any or unknown", () => { + // oxlint-disable-next-line no-explicit-any -- the case under test + expectTypeOf(tap(() => undefined)).returns.toEqualTypeOf< + number | Promise + >(); + expectTypeOf(tap(() => undefined)).returns.toEqualTypeOf< + number | Promise + >(); + }); + + test("passes the value on directly when the function returns a non-native thenable", () => { + const settled = Promise.resolve(); + // oxlint-disable-next-line no-thenable -- the case under test + const thenable: PromiseLike = { then: (onfulfilled) => settled.then(onfulfilled) }; + const result = pipe( + 1, + tap(() => thenable), + ); + expectTypeOf(result).toEqualTypeOf(); + expect(result).toBe(1); + }); + + test("rejects with the rejection of the promise the function returns", async () => { + const error = new Error("failed"); + await expect( + flow( + 1, + tap(() => Promise.reject(error)), + ), + ).rejects.toBe(error); + }); +}); + +test("the generated tests match generate-tests.ts", async () => { + expect(await readFile(new URL("generated.test.ts", import.meta.url), "utf8")).toBe( + await generate(), + ); +}); diff --git a/scripts/shared/pipe/index.ts b/scripts/shared/pipe/index.ts new file mode 100644 index 0000000..e113921 --- /dev/null +++ b/scripts/shared/pipe/index.ts @@ -0,0 +1,881 @@ +type Step = (a: A) => B; +type AsyncStep = (a: Awaited) => B; + +/** + * A last step of {@link flow} that takes the chain's promise rather than its value. Extend it with + * an `out` written in terms of `this["in"]`, the promise's type, to type what `sink` returns. + */ +export interface Sink { + readonly in: unknown; + readonly out: unknown; + readonly sink: (promise: never) => unknown; +} + +// Reads `out` with `in` set to `P`: a type-level call, since `sink` alone cannot carry a +// return type that depends on the promise. +type Apply = (F & { readonly in: P })["out"]; + +/** Passes `a` through each step from left to right. */ +export function pipe(a: A, ab: Step): B; +export function pipe(a: A, ab: Step, bc: Step): C; +export function pipe(a: A, ab: Step, bc: Step, cd: Step): D; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, +): E; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, +): F; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, +): G; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, +): H; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, +): I; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, +): J; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, +): K; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, +): L; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, +): M; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, +): N; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, +): O; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, +): P; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, + pq: Step, +): Q; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, + pq: Step, + qr: Step, +): R; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, + pq: Step, + qr: Step, + rs: Step, +): S; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, + pq: Step, + qr: Step, + rs: Step, + st: Step, +): T; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, + pq: Step, + qr: Step, + rs: Step, + st: Step, + tu: Step, +): U; +export function pipe(a: unknown, ...steps: Step[]): unknown { + return steps.reduce((value, step) => step(value), a); +} + +/** {@link pipe} that awaits `a` and every step's result before the next step. */ +export function flow(a: A, ab: AsyncStep): Promise>; +export function flow(a: A, ab: AsyncStep, bc: AsyncStep): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, + st: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, + st: AsyncStep, + tu: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, + st: AsyncStep, + last: Last, +): Apply>>; +export function flow< + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + Last extends Sink, +>( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, + st: AsyncStep, + tu: AsyncStep, + last: Last, +): Apply>>; +export function flow(a: unknown, ...steps: (Step | Sink)[]): unknown { + const last = steps.at(-1); + const sink = typeof last === "object" ? last : undefined; + const promise = (sink === undefined ? steps : steps.slice(0, -1)).reduce>( + (value, step) => value.then(step as Step), + Promise.resolve(a), + ); + return sink === undefined ? promise : sink.sink(promise as never); +} + +/** + * A step that calls `fn` with the value and passes the value on. When `fn` returns a promise, the + * step waits for it and passes the value on in a promise. + */ +// An `fn` that returns `any` or `unknown` gives `A | Promise`, since only the call tells which. +export function tap( + fn: (a: A) => R, +): (a: A) => unknown extends R ? A | Promise : R extends Promise ? Promise : A; +export function tap(fn: (a: A) => unknown): (a: A) => A | Promise { + return (a) => { + const result = fn(a); + return result instanceof Promise ? result.then(() => a) : a; + }; +} diff --git a/scripts/typescript-lines.ts b/scripts/shared/typescript-lines.ts similarity index 95% rename from scripts/typescript-lines.ts rename to scripts/shared/typescript-lines.ts index e737b62..6bb57fe 100644 --- a/scripts/typescript-lines.ts +++ b/scripts/shared/typescript-lines.ts @@ -8,7 +8,7 @@ import { promisify } from "node:util"; export const run = promisify(execFile); -export const root = fileURLToPath(new URL("../", import.meta.url)); +export const root = fileURLToPath(new URL("../../", import.meta.url)); /** * The floor the README claims, the line above it, and the current one, each installed under an diff --git a/scripts/shared/util.ts b/scripts/shared/util.ts new file mode 100644 index 0000000..95228d0 --- /dev/null +++ b/scripts/shared/util.ts @@ -0,0 +1,15 @@ +import { Result as R, type Err } from "better-result"; + +import type { Sink } from "./pipe/index.ts"; + +interface GenAwait extends Sink { + readonly out: this["in"] extends Promise> + ? AsyncGenerator, A> + : never; +} + +/** + * The last step of a `flow` that holds a Result: inside `Result.gen`, `yield*` the call to + * take the Ok value, or to stop at the Err. + */ +export const GenAwait = { sink: R.await } as unknown as GenAwait; diff --git a/scripts/ts-compatibility/index.ts b/scripts/ts-compatibility/index.ts index e7215e0..0fd346b 100644 --- a/scripts/ts-compatibility/index.ts +++ b/scripts/ts-compatibility/index.ts @@ -2,7 +2,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { docBlocks } from "../../docs/tools/twoslash/index.ts"; -import { aliases, pack, root, tsc, version } from "../typescript-lines.ts"; +import { aliases, pack, root, tsc, version } from "../shared/typescript-lines.ts"; const directory = join(root, "docs/.generated/ts-compatibility"); diff --git a/scripts/type-perf/index.ts b/scripts/type-perf/index.ts index 234652f..f3da161 100644 --- a/scripts/type-perf/index.ts +++ b/scripts/type-perf/index.ts @@ -5,7 +5,7 @@ // the times are printed and never checked. import { readFile, rm, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; -import { floor, pack, tsc, version, type Tsc } from "../typescript-lines.ts"; +import { floor, pack, tsc, version, type Tsc } from "../shared/typescript-lines.ts"; const here = new URL("./", import.meta.url);