diff --git a/.circleci/config.yml b/.circleci/config.yml index 6a98ee7dcc55..88e3d356498b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -204,6 +204,10 @@ jobs: - run: command: | yarn run tsc --project tsconfig.test.json --noEmit + - run: + # See scripts/typeperf/README.md + name: Type-check perf budget + command: yarn run check:typeperf unit_tests: parameters: diff --git a/.cursor/rules/ci-config.mdc b/.cursor/rules/ci-config.mdc index 84a9c7171d3c..e5c24f35c5fa 100644 --- a/.cursor/rules/ci-config.mdc +++ b/.cursor/rules/ci-config.mdc @@ -18,6 +18,7 @@ alwaysApply: false - `scripts/build-legacy-types.sh` builds each TS version concurrently; each `ts/` gets the downleveled `lib` (with `abstract new` rewritten to `new` below 4.2, which `downlevel-dts` misses), then every newer version's `src-*-types` overlay, then its own. Keep overlays to small single-purpose modules (like `NoInfer.ts`, `tupleTypes.ts`) so whole-file copies can't go stale. - `esmodule-types` also runs `examples/todo-app/tsconfig.typetest-libcheck.json` (`skipLibCheck: false`, `types: []`) so errors inside the legacy outputs fail CI, and `esmodule-types-latest` runs it with `--moduleResolution bundler` (TS 7 removed `node`) to cover `lib/`; the other typetests use `skipLibCheck: true`. - Any change to legacy types building must leave `ts*/` output byte-identical to master (diff it) unless it intentionally changes published types (then add a changeset). +- `typecheck` also runs `yarn check:typeperf` ([scripts/typeperf](../../scripts/typeperf/README.md)), which reads the `ci:build:types` output from `setup`'s workspace. - Never `git fetch --depth` the base branch in the relevance check: a shallow fetch severs the merge base and the three-dot diff fails. - Changing root `package.json` `workspaces` requires updating the `setup` job's workspace trimming step. diff --git a/AGENTS.md b/AGENTS.md index b73593a1a67b..adf688ba1d15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Monorepo for `@data-client` high performance npm packages. - `yarn build` - Build all packages - `yarn test` - Run tests (Jest projects: ReactDOM, Node, ReactNative) - `yarn lint` / `yarn format` - Linting and formatting +- `yarn check:typeperf` - Type-check cost budget (CI `typecheck` job); run after `yarn ci:build:types` when changing public types, `--update` to re-record. See `scripts/typeperf/README.md` - Website: `yarn workspace rdc-website typecheck` / `yarn lint --quiet 'website/src/**/*.{ts,tsx}'` / `yarn workspace rdc-website build`; dev: `cd website && yarn start:vscode` **Test naming**: `*.node.test.ts[x]` (Node), `*.native.test.ts[x]` (RN), `*.test.ts[x]` (regular) diff --git a/package.json b/package.json index e6518e69d7c5..1a4c17ec8d12 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "changeset:version:beta": "run changeset version --snapshot beta && YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install", "changeset:publish:beta": "run build && yarn workspaces foreach -A --no-private --topological npm publish --tolerate-republish --access public --tag beta", "lint": "eslint", + "check:typeperf": "node scripts/typeperf/check.mjs", "format": "eslint --fix \"packages/*/src/**/*.{js,ts,tsx,cts}\"", "clean:types": "rimraf packages/*/*.tsbuildinfo", "build": "yarn build:types && yarn workspaces foreach -Wptiv --no-private run build", diff --git a/scripts/typeperf/.gitignore b/scripts/typeperf/.gitignore new file mode 100644 index 000000000000..c761a104217f --- /dev/null +++ b/scripts/typeperf/.gitignore @@ -0,0 +1 @@ +scenarios/ diff --git a/scripts/typeperf/README.md b/scripts/typeperf/README.md new file mode 100644 index 000000000000..c76f316352d5 --- /dev/null +++ b/scripts/typeperf/README.md @@ -0,0 +1,49 @@ +# Type-check performance budget + +Guards how much work TypeScript does to check code that uses `@data-client` types. +CI runs it in the `typecheck` job; run it locally after `yarn ci:build:types`: + +```bash +yarn check:typeperf # every fixture +yarn check:typeperf paths # one fixture +yarn check:typeperf --update # re-record budget.json after an intended change +``` + +It fails when: + +- a stress fixture's **instantiation count** rises more than 10% over [budget.json](./budget.json). + Instantiations are deterministic for a given TypeScript version, so they make a stable + signal; check times are printed for context only. +- a fixture has type errors. + The `patheq` fixture's errors mean the path types (`PathKeys`, `PathArgs`, `ShortenPath`, + `PathArgsAndSearch`, `KeysToArgs`) disagree with the frozen reference implementation in + [patheq/orig.ts](./patheq/orig.ts) on one of ~1500 fuzzed paths. If a path type's behavior + changes on purpose, update `orig.ts` to match. + +When a count drops more than 10% below its budget, the check says so; re-record it with +`--update` so the budget catches regressions from the new level. + +## Fixtures + +[gen.mjs](./gen.mjs) writes `scenarios//` (gitignored). `N=2` scales them up. + +| Fixture | What it stresses | +|---|---| +| resources | 40 `resource()`s × React hooks and Controller calls | +| bigEntity | a 300-field Entity | +| union | a 30-member Union in Collection, Values and nested schemas | +| paths | 150 RestEndpoints with long paths, `extend()` and `paginated()` | +| nested | 12 levels of nested Entity relations | +| vue | 40 resources × Vue composables | +| schemas | Query, All, Invalidate, Array and Object schemas | +| typical | a small, realistic app | +| setValues | `ctrl.set()` values on a Union, Collection and big Entity | +| setUpdaters | `ctrl.set()` updaters spreading `prev` on a 30-member Union | +| patheq | path types against [patheq/orig.ts](./patheq/orig.ts) on fixed-seed fuzzed paths | + +To compare TypeScript 6 and 7 or check time, run a compiler on a fixture directly: + +```bash +npx tsc6 -p scripts/typeperf/scenarios/paths/tsconfig.json --extendedDiagnostics +npx tsc -p scripts/typeperf/scenarios/paths/tsconfig.json --extendedDiagnostics +``` diff --git a/scripts/typeperf/budget.json b/scripts/typeperf/budget.json new file mode 100644 index 000000000000..ecc22070e1d0 --- /dev/null +++ b/scripts/typeperf/budget.json @@ -0,0 +1,16 @@ +{ + "instantiations": { + "bigEntity": 17159, + "nested": 2895, + "patheq": 935715, + "paths": 341361, + "resources": 134294, + "schemas": 101186, + "setUpdaters": 128293, + "setValues": 89601, + "typical": 12995, + "union": 13578, + "vue": 47288 + }, + "typescript": "6.0.3" +} diff --git a/scripts/typeperf/check.mjs b/scripts/typeperf/check.mjs new file mode 100644 index 000000000000..773e4419568d --- /dev/null +++ b/scripts/typeperf/check.mjs @@ -0,0 +1,114 @@ +// Type-check performance budget for @data-client's public types. +// +// Generates the fixtures (gen.mjs), type-checks each with the `typescript` package, +// and fails when a fixture has errors or its instantiation count exceeds budget.json +// by more than TOLERANCE. Unlike check time, instantiations are deterministic for a +// given TypeScript version, so they are what the budget tracks. +// +// usage: yarn check:typeperf [--update] [scenario...] +// --update rewrite budget.json with the current counts (after an intended change) +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +import { generate } from './gen.mjs'; + +const dir = path.dirname(fileURLToPath(import.meta.url)); +const budgetFile = path.join(dir, 'budget.json'); +const TOLERANCE = 0.1; + +const args = process.argv.slice(2); +const update = args.includes('--update'); +const only = args.filter(a => !a.startsWith('--')); +const budget = JSON.parse(fs.readFileSync(budgetFile, 'utf8')); + +// Share parsed lib and @data-client .d.ts files between fixtures; each program still +// gets its own checker, so instantiation counts are unaffected. +const sourceFiles = new Map(); +const host = options => { + const h = ts.createCompilerHost(options); + const getSourceFile = h.getSourceFile; + h.getSourceFile = (file, ...rest) => { + if (file.includes('/scenarios/')) return getSourceFile(file, ...rest); + if (!sourceFiles.has(file)) + sourceFiles.set(file, getSourceFile(file, ...rest)); + return sourceFiles.get(file); + }; + return h; +}; + +const failures = []; +const lowered = []; +let needsUpdate = false; +// A full --update re-records from scratch, dropping removed fixtures +if (update && !only.length) budget.instantiations = {}; +console.log(`TypeScript ${ts.version}\n`); +console.log('scenario\tinstantiations\tbudget\tchange\ttime'); +for (const s of generate(only)) { + const cfg = ts.getParsedCommandLineOfConfigFile( + path.join(dir, 'scenarios', s, 'tsconfig.json'), + {}, + { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} }, + ); + const start = performance.now(); + const program = ts.createProgram( + cfg.fileNames, + cfg.options, + host(cfg.options), + ); + const diagnostics = ts.getPreEmitDiagnostics(program); + const seconds = (performance.now() - start) / 1000; + const count = program.getInstantiationCount(); + if (diagnostics.length) { + failures.push(`${s}: ${diagnostics.length} type errors`); + console.error( + ts.formatDiagnostics(diagnostics.slice(0, 10), { + getCanonicalFileName: f => f, + getCurrentDirectory: () => process.cwd(), + getNewLine: () => '\n', + }), + ); + } + + const max = budget.instantiations[s]; + const change = max ? (count - max) / max : 0; + const pct = + max ? `${change >= 0 ? '+' : ''}${(change * 100).toFixed(1)}%` : 'new'; + console.log(`${s}\t${count}\t${max ?? '-'}\t${pct}\t${seconds.toFixed(1)}s`); + if (update) { + budget.instantiations[s] = count; + } else if (!max) { + needsUpdate = true; + failures.push(`${s}: has no budget`); + } else if (change > TOLERANCE) { + needsUpdate = true; + failures.push( + `${s}: ${count} instantiations is ${pct} over its budget of ${max}`, + ); + } else if (change < -TOLERANCE) { + lowered.push(s); + } +} + +if (update) { + budget.typescript = ts.version; + fs.writeFileSync(budgetFile, JSON.stringify(budget, null, 2) + '\n'); + console.log(`\nUpdated ${path.relative(process.cwd(), budgetFile)}`); +} else if (budget.typescript !== ts.version) { + console.log( + `\nbudget.json was recorded with TypeScript ${budget.typescript}; re-record it with --update if the compiler upgrade moved the counts.`, + ); +} +if (lowered.length) + console.log( + `\n${lowered.join(', ')} dropped more than ${TOLERANCE * 100}% below budget; run with --update to lock in the win.`, + ); +if (failures.length) { + console.error(`\n${failures.join('\n')}`); + if (needsUpdate) + console.error( + '\nIf the change is intended, run `yarn check:typeperf --update` and commit budget.json.', + ); + process.exit(1); +} diff --git a/scripts/typeperf/gen.mjs b/scripts/typeperf/gen.mjs new file mode 100644 index 000000000000..d19a13f6177b --- /dev/null +++ b/scripts/typeperf/gen.mjs @@ -0,0 +1,270 @@ +// Generates extreme-case type-checking fixtures for @data-client public types. +// usage: node scripts/typeperf/gen.mjs [scenario...] (N=2 scales every fixture up) +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import patheq from './patheq/gen.mjs'; +const dir = path.dirname(fileURLToPath(import.meta.url)); +const out = path.join(dir, 'scenarios'); +const N = Number(process.env.N ?? 1); +const scen = {}; +const fields = (n, p = 'f') => + Array.from( + { length: n }, + (_, i) => ` ${p}${i} = ${["''", '0', 'false'][i % 3]};`, + ).join('\n'); + +// M-member Union `Un` and BIG-field Entity `Big`, shared by several fixtures +const M = 30; +const BIG = 300; +const unionDefs = () => + Array.from( + { length: M }, + (_, i) => + `export class U${i} extends Entity {\n id = '';\n readonly type = 'u${i}' as const;\n${fields(15, `m${i}_`)}\n static key = 'U${i}';\n}\n`, + ).join('') + + `export const Un = new schema.Union({ ${Array.from({ length: M }, (_, i) => `u${i}: U${i}`).join(', ')} }, 'type');\n`; +const bigDefs = () => + `export class Big extends Entity {\n id = '';\n${fields(BIG)}\n static key = 'Big';\n}\n`; + +// 1. many resources + react hooks +scen.resources = () => { + let s = `import { Entity, resource } from '@data-client/rest'; +import { useSuspense, useController, useCache, useQuery, useLive, useDLE, useFetch } from '@data-client/react'; +`; + const R = 40 * N; + for (let i = 0; i < R; i++) { + s += `export class E${i} extends Entity {\n id = '';\n${fields(20)}\n static key = 'E${i}';\n}\n`; + s += `export const R${i} = resource({ path: '/e${i}/:id', schema: E${i}, searchParams: {} as { q?: string } | undefined, paginationField: 'cursor' });\n`; + } + s += `export function useAll() {\n const ctrl = useController();\n`; + for (let i = 0; i < R; i++) { + s += ` { + const one = useSuspense(R${i}.get, { id: '1' }); + const list = useSuspense(R${i}.getList, { q: 'x' }); + const c = useCache(R${i}.getList); + const d = useDLE(R${i}.get, { id: '1' }); + useFetch(R${i}.get, { id: '1' }); + const l = useLive(R${i}.getList); + const s0: string = one.f0; const n1: number = list[0].f1; + ctrl.fetch(R${i}.update, { id: '1' }, { f0: 'x' }); + ctrl.fetch(R${i}.partialUpdate, { id: '1' }, { f1: 5 }); + ctrl.fetch(R${i}.getList.push, { f0: 'y' }); + ctrl.fetch(R${i}.getList.getPage, { cursor: '2' }); + ctrl.fetch(R${i}.delete, { id: '1' }); + ctrl.set(E${i}, { id: '1' }, { f0: 'z' }); + ctrl.invalidate(R${i}.get, { id: '1' }); + const q = useQuery(R${i}.getList.schema, { q: 'x' }); + void s0, n1, c, d, l, q; + }\n`; + } + s += `}\n`; + return s; +}; + +// 2. 300-field entity +scen.bigEntity = () => { + let s = `import { Entity, resource } from '@data-client/rest'; +import { useSuspense, useController } from '@data-client/react'; +${bigDefs()}export const BigResource = resource({ path: '/big/:id', schema: Big }); +export function useBig() {\n const ctrl = useController();\n`; + for (let i = 0; i < 100 * N; i++) { + s += ` { const b = useSuspense(BigResource.get, { id: '${i}' }); const x: string = b.f${(i * 3) % BIG}; ctrl.fetch(BigResource.partialUpdate, { id: '${i}' }, { f${(i * 3) % BIG}: 'a' }); ctrl.set(Big, { id: '${i}' }, { f${(i * 3) % BIG}: 'b' }); void x; }\n`; + } + return s + '}\n'; +}; + +// 3. 30-member Union, Collection of union, Values, nested +scen.union = () => { + let s = `import { Entity, schema, Collection, RestEndpoint } from '@data-client/rest'; +import { useSuspense, useController } from '@data-client/react'; +${unionDefs()}export const getFeed = new RestEndpoint({ path: '/feed', schema: new Collection([Un]) }); +export const getOne = new RestEndpoint({ path: '/feed/:id', schema: Un }); +export const getVals = new RestEndpoint({ path: '/vals', schema: new schema.Values(Un) }); +export const getObj = new RestEndpoint({ path: '/obj', schema: { feed: [Un], extra: new schema.Values(Un), nested: { a: Un } } }); +export function useU() {\n const ctrl = useController();\n`; + for (let i = 0; i < 50 * N; i++) + s += ` { const f = useSuspense(getFeed); const o = useSuspense(getOne, { id: '${i}' }); const v = useSuspense(getVals); const ob = useSuspense(getObj); + const t: string = f[0].type; if (o.type === 'u${i % M}') { const z = o.m${i % M}_0; void z; } void v, ob, t; + ctrl.fetch(getFeed.push, { type: 'u${i % M}', m${i % M}_0: 'x' } as any); + ctrl.set(getFeed.schema, [{ id: '1', type: 'u${i % M}' }]); }\n`; + return s + '}\n'; +}; + +// 4. path params / endpoint extension chains +scen.paths = () => { + let s = `import { RestEndpoint, Entity } from '@data-client/rest'; +import { useSuspense, useController } from '@data-client/react'; +export class P extends Entity { id = ''; a = ''; static key = 'P'; } +`; + for (let i = 0; i < 150 * N; i++) { + s += `export const ep${i} = new RestEndpoint({ path: '/org/:org${i}/repo/:repo/issues/:issue/comments/:comment{/:sub}/x/:y', schema: P, searchParams: {} as { page?: number } }); +export const ep${i}b = ep${i}.extend({ path: '/other/:a/:b/:c', method: 'POST', body: {} as { a: string } }).extend({ dataExpiryLength: 5 }); +export const ep${i}c = ep${i}.paginated('cursor'); +`; + } + s += `export function useP() { const ctrl = useController();\n`; + for (let i = 0; i < 150 * N; i++) + s += ` useSuspense(ep${i}, { org${i}: 'a', repo: 'b', issue: 1, comment: 2, y: 'q', page: 1 }); ctrl.fetch(ep${i}b, { a: '1', b: '2', c: '3' }, { a: 'x' }); ctrl.fetch(ep${i}c, { org${i}: 'a', repo: 'b', issue: 1, comment: 2, y: 'q', cursor: 'n' });\n`; + return s + '}\n'; +}; + +// 5. deep nested relations + denormalize +scen.nested = () => { + let s = `import { Entity, resource, schema, Collection } from '@data-client/rest'; +import { useSuspense } from '@data-client/react'; +import type { Denormalize, DenormalizeNullable, Normalize } from '@data-client/endpoint'; +`; + const D = 12; + s += `export class L0 extends Entity { id = ''; v0 = ''; static key = 'L0'; }\n`; + for (let d = 1; d < D; d++) + s += `export class L${d} extends Entity { id = ''; v${d} = 0; child = L${d - 1}.fromJS(); kids: L${d - 1}[] = []; static key = 'L${d}'; static schema = { child: L${d - 1}, kids: [L${d - 1}], coll: new Collection([L${d - 1}]), map: new schema.Values(L${d - 1}) }; }\n`; + s += `export const LR = resource({ path: '/l/:id', schema: L${D - 1} }); +export function useL() {\n`; + for (let i = 0; i < 60 * N; i++) + s += ` { const x = useSuspense(LR.get, { id: '${i}' }); const v: string = x.${'child.'.repeat(D - 1)}v0; type A${i} = Denormalize; type B${i} = Normalize; type C${i} = DenormalizeNullable; void v; }\n`; + return s + '}\n'; +}; + +// 6. vue composables +scen.vue = () => { + let s = `import { Entity, resource } from '@data-client/rest'; +import { useSuspense, useController, useCache, useQuery, useLive, useDLE, useFetch } from '@data-client/vue'; +`; + const R = 40 * N; + for (let i = 0; i < R; i++) { + s += `export class V${i} extends Entity {\n id = '';\n${fields(20)}\n static key = 'V${i}';\n}\n`; + s += `export const VR${i} = resource({ path: '/v${i}/:id', schema: V${i}, searchParams: {} as { q?: string } | undefined });\n`; + } + s += `export async function setup() {\n const ctrl = useController();\n`; + for (let i = 0; i < R; i++) + s += ` { const one = await useSuspense(VR${i}.get, { id: '1' }); const list = await useSuspense(VR${i}.getList, { q: 'x' }); const c = useCache(VR${i}.getList); const d = useDLE(VR${i}.get, { id: '1' }); useFetch(VR${i}.get, { id: '1' }); const l = await useLive(VR${i}.getList); const q = useQuery(VR${i}.getList.schema); ctrl.fetch(VR${i}.update, { id: '1' }, { f0: 'x' }); void one, list, c, d, l, q; }\n`; + return s + '}\n'; +}; + +// 7. Query/All/Scalar/Invalidate/Lazy grab bag +scen.schemas = () => { + let s = `import { Entity, resource, schema, Collection, Query, RestEndpoint } from '@data-client/rest'; +import { useSuspense, useQuery, useController } from '@data-client/react'; +`; + for (let i = 0; i < 40 * N; i++) { + s += `export class S${i} extends Entity { id = ''; a = 0; b = ''; static key = 'S${i}'; } +export const SR${i} = resource({ path: '/s${i}/:id', schema: S${i} }); +export const all${i} = new schema.All(S${i}); +export const q${i} = new Query(all${i}, (rows, { min }: { min?: number }) => rows.filter(r => r.a > (min ?? 0))); +export const inv${i} = new RestEndpoint({ path: '/s${i}/:id', method: 'DELETE', schema: new schema.Invalidate(S${i}) }); +export const arr${i} = new RestEndpoint({ path: '/arr${i}', schema: new schema.Array(S${i}) }); +export const obj${i} = new RestEndpoint({ path: '/obj${i}', schema: new schema.Object({ list: [S${i}], one: S${i}, coll: new Collection([S${i}], { argsKey: ({ g }: { g: string }) => ({ g }) }) }) }); +`; + } + s += `export function useS() { const ctrl = useController();\n`; + for (let i = 0; i < 40 * N; i++) + s += ` { const r = useQuery(q${i}, { min: 2 }); const a = useQuery(all${i}); const o = useSuspense(obj${i}); const ar = useSuspense(arr${i}); ctrl.fetch(inv${i}, { id: '1' }); ctrl.set(all${i}, [{ id: '1', a: 1 }]); void r, a, o, ar; }\n`; + return s + '}\n'; +}; + +// 8. typical app: a handful of resources, extend/paginated, a plain Endpoint, components +scen.typical = () => { + let s = `import { Entity, resource, RestEndpoint, Endpoint, schema } from '@data-client/rest'; +import { useSuspense, useController, useCache, useDLE } from '@data-client/react'; +export class User extends Entity { id = ''; name = ''; email = ''; static key = 'User'; } +export class Comment extends Entity { id = ''; body = ''; author = User.fromJS(); static key = 'Comment'; static schema = { author: User }; } +export class Article extends Entity { id = ''; title = ''; body = ''; author = User.fromJS(); comments: Comment[] = []; tags: string[] = []; static key = 'Article'; static schema = { author: User, comments: [Comment] }; } +export const UserResource = resource({ path: '/users/:id', schema: User }); +export const ArticleResource = resource({ path: '/articles/:id', schema: Article, searchParams: {} as { tag?: string; page?: number } | undefined, paginationField: 'page' }) + .extend({ get: { dataExpiryLength: 60000 } }) + .extend('search', { path: '/articles/search', searchParams: {} as { q: string }, schema: new schema.Collection([Article]) }); +export const CommentResource = resource({ path: '/articles/:articleId/comments/:id', schema: Comment, optimistic: true }); +export const login = new RestEndpoint({ path: '/login', method: 'POST', body: {} as { email: string; password: string }, schema: User }); +export const logout = login.extend({ path: '/logout', body: undefined, schema: undefined }); +export const getStats = new Endpoint(async ({ id }: { id: string }) => ({ views: 1, id }), { key: ({ id }) => 'stats' + id }); +export const slowStats = getStats.extend({ dataExpiryLength: 1000 }); +export const paged = ArticleResource.getList.paginated('page'); +`; + for (let i = 0; i < 20 * N; i++) { + s += `export function Comp${i}({ id }: { id: string }) { + const ctrl = useController(); + const article = useSuspense(ArticleResource.get, { id }); + const list = useSuspense(ArticleResource.getList, { tag: 't${i}' }); + const found = useSuspense(ArticleResource.search, { q: 'x' }); + const cached = useCache(UserResource.get, { id: article.author.id }); + const { data } = useDLE(CommentResource.getList, { articleId: id }); + const st = useSuspense(getStats, { id }); + const t: string = article.title; const n: number = list.length + found.length + st.views; + const onSave = () => ctrl.fetch(ArticleResource.partialUpdate, { id }, { title: 'x' }); + const onComment = () => ctrl.fetch(CommentResource.getList.push, { articleId: id }, { body: 'hi' }); + const onNext = () => ctrl.fetch(paged, { tag: 't${i}', page: 2 }); + const onLogin = () => ctrl.fetch(login, { email: 'a', password: 'b' }); + const onLogout = () => ctrl.fetch(logout); + const onDel = () => ctrl.fetch(CommentResource.delete, { articleId: id, id: '1' }); + void t, n, cached, data, onSave, onComment, onNext, onLogin, onLogout, onDel; + return null; +} +`; + } + return s; +}; + +// 9. #4133's set() cases: values and updaters on a 30-member Union, a Collection of it, and a 300-field Entity +const setHeader = + () => `import { Entity, schema, Collection } from '@data-client/rest'; +import { useController } from '@data-client/react'; +${unionDefs()}export const Feed = new Collection([Un]); +${bigDefs()}export function useS() { + const ctrl = useController(); +`; +scen.setValues = () => { + let s = setHeader(); + for (let i = 0; i < 333 * N; i++) + s += ` ctrl.set(Un, { id: '${i}', type: 'u${i % M}' }, { id: '${i}', type: 'u${i % M}', m${i % M}_0: 'x' }); + ctrl.set(Feed, [{ id: '${i}', type: 'u${i % M}', m${i % M}_0: 'x' }]); + ctrl.set(Big, { id: '${i}' }, { f${(i * 3) % BIG}: '' });\n`; + return s + '}\n'; +}; +scen.setUpdaters = () => { + let s = setHeader(); + for (let i = 0; i < 1000 * N; i++) + s += ` ctrl.set(Un, { id: '${i}', type: 'u${i % M}' }, prev => ({ ...prev, m${i % M}_0: 'x' }));\n`; + return s + '}\n'; +}; + +// 10. path types vs their frozen pre-#4173 implementation (a type error is a mismatch) +scen.patheq = patheq; + +/** Writes scenarios// for the named fixtures (all when empty); returns the names */ +export function generate(only = []) { + const names = Object.keys(scen).filter(n => !only.length || only.includes(n)); + if (!only.length) fs.rmSync(out, { recursive: true, force: true }); + for (const name of names) { + const fn = scen[name]; + const d = path.join(out, name); + fs.rmSync(d, { recursive: true, force: true }); + fs.mkdirSync(d, { recursive: true }); + fs.writeFileSync(path.join(d, 'index.ts'), fn()); + fs.writeFileSync( + path.join(d, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'esnext', + module: 'esnext', + lib: ['dom', 'esnext'], + strict: true, + moduleResolution: 'bundler', + skipLibCheck: true, + noEmit: true, + types: [], + }, + files: ['index.ts'], + }, + null, + 2, + ), + ); + } + return names; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) + console.log('generated', generate(process.argv.slice(2)).join(' ')); diff --git a/scripts/typeperf/patheq/gen.mjs b/scripts/typeperf/patheq/gen.mjs new file mode 100644 index 000000000000..a4927ec418bc --- /dev/null +++ b/scripts/typeperf/patheq/gen.mjs @@ -0,0 +1,122 @@ +// Fixture asserting @data-client/rest's PathKeys/PathArgs/ShortenPath/PathArgsAndSearch/KeysToArgs +// match the frozen pre-#4173 implementation in orig.ts for a fixed-seed fuzzed corpus of paths. +import fs from 'node:fs'; +const chars = [ + '/', + '\\\\', + '%', + '&', + '*', + ':', + '{', + '}', + ';', + ',', + '!', + '@', + '"', + 'a', + 'b', + 'id', + 'x', + '-', + '.', + '?', + '+', + '(', + ')', + '#', + ' ', + '0', +]; +// mulberry32: a fixed seed keeps the corpus (and its instantiation count) stable +let seed = 1; +const rnd = n => { + let t = (seed = (seed + 0x6d2b79f5) | 0); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) % n; +}; +const fixed = [ + '', + '/', + ':', + '*', + '::', + '**', + ':*', + '*:', + '/a/:', + '/a/*', + '/a/:b', + '/a/*b', + '/a/:b/:c', + '/a/:b{/:c}', + '/a{/:b}/:c', + '/org/:org0/repo/:repo/issues/:issue/comments/:comment{/:sub}/x/:y', + '/files/*rest/:x{/*opt}', + '/a\\\\:b/:c\\\\*d/:e', + '/a/:"quoted"/:b;:c,:d!:e@:f%:g&:h', + '/a/:b\\\\}c', + '/:a/:b/:c/:d/:e/:f/:g/:h/:i/:j', + '/a/:b@c/d', + '/a/:b/c@d', + ':a/b:c', + '/a/:b{', + '/a/:b}', + '/a/{:b}', + '/a/:b{/:c{/:d}}', + '/a/*b{/*c}', + '/x/:id.json', + '/x/:id-:slug', + '/x/:id?q=1', + '/:a:b:c', + '/a\\\\', + '\\\\:', + '\\\\:a', + '/a/:"b"', + '/a/:"b', + '/a/*"b"', + '/a/:b"c"', + 'http://x.com/:id', + '/a/:b\\\\', + '/:a\\\\*b', +]; +const paths = [...fixed]; +for (let i = 0; i < 1500; i++) { + let s = ''; + const len = 1 + rnd(14); + for (let j = 0; j < len; j++) s += chars[rnd(chars.length)]; + paths.push(s); +} +const orig = fs.readFileSync(new URL('./orig.ts', import.meta.url), 'utf8'); +let out = `import type { PathKeys, PathArgs, ShortenPath, PathArgsAndSearch, KeysToArgs } from '@data-client/rest';\n${orig} +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false;\nfunction ok() {}\n`; +paths.forEach(p => { + const l = "'" + p.replace(/'/g, "\\'") + "'"; + out += `ok, OPathKeys<${l}>>>(); ok, OPathArgs<${l}>>>(); ok, OShortenPath<${l}>>>(); ok, OPathArgsAndSearch<${l}>>>();\n`; +}); +const ktas = [ + `'a'`, + `'a}'`, + `'*a'`, + `'*a}'`, + `'a' | '*a'`, + `'a}' | '*a}'`, + `'a' | 'a}'`, + `'"a"' | 'a'`, + `'"a"}' | '*"a"'`, + `string`, + `'a' | 'b}' | '*c' | '*d}' | '"e"' | '"f"}'`, + `never`, + `'' | '}'`, + `'*' | '*}'`, + `'"' | '""' | '"}'`, + `\`\${string}}\``, + `\`*\${string}\``, +]; +// KTA: KeysToArgs identity on hand-picked key unions +ktas.forEach(k => { + out += `ok, OKeysToArgs<${k}>>>();\n`; +}); +export default () => out; diff --git a/scripts/typeperf/patheq/orig.ts b/scripts/typeperf/patheq/orig.ts new file mode 100644 index 000000000000..b344b8cc035a --- /dev/null +++ b/scripts/typeperf/patheq/orig.ts @@ -0,0 +1,78 @@ +type OCleanKey = S extends `"${infer K}"` ? K : S; + +type OKeyName = OCleanKey< + K extends `*${infer N}}` ? N + : K extends `*${infer N}` ? N + : K extends `${infer N}}` ? N + : K +>; + +type OKeyVal = + K extends `*${string}` ? string[] : string | number; + +/** Parameters for a given path */ +type OPathArgs = + unknown extends S ? any + : OPathKeys extends never ? + // unknown is identity for intersection ('&') + unknown + : OKeysToArgs>; + +/** Like {@link PathArgs} but widened `path: string` collapses to `unknown`, + * preventing `(params, body) | (body)` union overloads in ParamFetchWithBody. */ +type OSoftPathArgs

= + unknown extends P ? any + : string extends P ? unknown + : OPathArgs

; + +/** Computes the union of keys for a path string */ +type OPathKeys = + string extends S ? string + : S extends `${infer A}\\${':' | '*' | '}'}${infer B}` ? + OPathKeys | OPathKeys + : OSplits | OSplits; + +type OSplits = + S extends `${string}${M}${infer K}${M}${infer R}` ? + OSplits<`${M}${K}`, M> | OSplits<`${M}${R}`, M> + : S extends ( + `${string}${M}${infer K}${'/' | '\\' | '%' | '&' | '*' | ':' | '{' | ';' | ',' | '!' | '@'}${infer R}` + ) ? + OSplits<`${M}${K}`, M> | OSplits + : S extends `${string}${M}${infer K}` ? + M extends '*' ? + `*${K}` + : K + : never; + +type OKeysToArgs = { + [K in Key as K extends `${string}}` ? OKeyName : never]?: OKeyVal; +} & (Exclude extends never ? unknown +: { + [K in Key as K extends `${string}}` ? never : OKeyName]: OKeyVal; + }); + +type OPathArgsAndSearch = + unknown extends S ? any + : Exclude, `${string}}`> extends never ? + Record | undefined + : { + [ + K in OPathKeys as K extends `${string}}` ? never : OKeyName + ]: OKeyVal; + } & Record; + +/** Removes the last :param or *wildcard token */ +type OShortenPath = + string extends S ? string + : S extends `${infer B}:${infer R}` ? OTrimToken<`${B}:${OShortenPath}`> + : S extends `${infer B}*${infer R}` ? OTrimToken<`${B}*${OShortenPath}`> + : ''; + +type OTrimToken = + string extends S ? string + : S extends `${infer R}:` ? R + : S extends `${infer R}*` ? R + : S; + +type OResourcePath = string; // `${string}:${string}`; TODO: Maybe do this in the future? Seems to hard to understand for now