Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/native-typescript-builds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@cleverbrush/server": patch
"@cleverbrush/knex-schema": patch
"@cleverbrush/scheduler-postgres": patch
---

Build and validate the framework with the pinned TypeScript 7.1 native nightly.
Declare endpoint builder variance so large contracts stay within the native
compiler's instantiation limits, and preserve internal query and scheduler
schema types without changing their runtime behavior.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
run: npm run build:schema-site && npm run build:docs-site

- name: Generate every package API reference
run: npx typedoc --out "$RUNNER_TEMP/framework-api-docs"
run: npm run docs:generate -- --out "$RUNNER_TEMP/framework-api-docs"

- uses: actions/upload-artifact@v4
if: always()
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ ES modules.

Use Node.js 24 or newer and npm 11. Install the Node version in `.nvmrc`.

Builds and typechecks use the pinned native compiler,
`typescript@7.1.0-dev.20261007.1`. The `tsc` command also powers declaration
generation, Vitest type tests, website builds, and consumer fixtures.
TypeDoc alone uses TypeScript 6.0.3 in the private `scripts/api-docs` workspace
until [TypeDoc supports TypeScript 7](https://github.com/TypeStrong/typedoc/issues/3098).
Use `npm run docs:generate -- --out <directory>` to generate API references
through that isolated toolchain, or `npm run docs` for the website output.

```bash
npm ci
npm run lint
Expand All @@ -108,6 +116,19 @@ npm run build:schema-site
npm run build:docs-site
```

To compare two disposable checkouts after running `npm ci` in each:

```bash
node scripts/benchmark-build.mjs --baseline /tmp/framework-baseline \
--candidate /tmp/framework-candidate --runs 5 --output /tmp/build-results.json
```

The benchmark removes generated package outputs, bypasses Turbo cache reads,
and alternates the checkouts after a warm-up. It records clean full builds,
compiler-only declaration generation, and warm incremental schema checks.
Run it without other builds or tests competing for CPU. Results and command
logs are written beside the requested JSON file.

The demo app can be started with:

```bash
Expand Down
2 changes: 1 addition & 1 deletion demos/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@types/node": "^22.0.0",
"@types/pg": "^8.11.10",
"@types/ws": "^8.5.13",
"typescript": "^6.0.2",
"typescript": "7.1.0-dev.20261007.1",
"vitest": "^4.1.11"
}
}
1 change: 1 addition & 0 deletions demos/todo-backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ COPY demos/todo-frontend/package.json ./demos/todo-frontend/
COPY websites/docs/package.json ./websites/docs/
COPY websites/schema/package.json ./websites/schema/
COPY websites/shared/package.json ./websites/shared/
COPY scripts/api-docs/package.json ./scripts/api-docs/

# Install all workspace dependencies
RUN npm ci --ignore-scripts
Expand Down
2 changes: 1 addition & 1 deletion demos/todo-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"@types/node": "^22.0.0",
"tsup": "^8.5.1",
"tsx": "^4.19.0",
"typescript": "^6.0.2"
"typescript": "7.1.0-dev.20261007.1"
},
"type": "module",
"types": "./dist/index.d.ts",
Expand Down
1 change: 1 addition & 0 deletions demos/todo-frontend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ COPY demos/todo-frontend/package.json ./demos/todo-frontend/
COPY websites/docs/package.json ./websites/docs/
COPY websites/schema/package.json ./websites/schema/
COPY websites/shared/package.json ./websites/shared/
COPY scripts/api-docs/package.json ./scripts/api-docs/

# Install workspace dependencies
RUN npm ci --ignore-scripts
Expand Down
2 changes: 1 addition & 1 deletion demos/todo-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^6.0.2",
"typescript": "7.1.0-dev.20261007.1",
"vite": "^6.4.3"
}
}
2 changes: 1 addition & 1 deletion libs/knex-schema/src/SchemaQueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1610,7 +1610,7 @@ export class SchemaQueryBuilder<

private writer(filtered = true): QuerySource<S, InferType<Row>> {
this.assertWritable();
let base = this.knex(getTableName(this.source));
let base: Knex.QueryBuilder = this.knex(getTableName(this.source));
if (filtered) {
const keys = getPrimaryKeyColumns(this.source).columnNames;
if (keys.length) {
Expand Down
136 changes: 70 additions & 66 deletions libs/knex-schema/src/property-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { LanguageVariant, SyntaxKind } from 'typescript/unstable/ast';
import { createScanner } from 'typescript/unstable/ast/scanner';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { typescriptCli } from '../../../scripts/typescript-cli.mjs';
import {
type Location,
NativeLanguageService,
positionAt
} from '../test-fixtures/native-language-service.js';

// Exercise the whole public type pipeline in one language service, including
// downstream ORM, mapper, form and client consumers of the emitted declarations.
Expand Down Expand Up @@ -94,54 +102,38 @@ const references = [...sources].flatMap(([file, source]) =>
}))
);

let service: ts.LanguageService;
beforeAll(() => {
const options: ts.CompilerOptions = {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
strict: true,
skipLibCheck: true,
allowJs: true,
checkJs: true,
noEmit: true,
esModuleInterop: true,
types: ['node']
};
service = ts.createLanguageService({
...ts.sys,
useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
getScriptFileNames: () => [...sources.keys()],
getScriptVersion: () => '0',
getScriptSnapshot: file => {
const text = sources.get(file) ?? ts.sys.readFile(file);
return text === undefined
? undefined
: ts.ScriptSnapshot.fromString(text);
},
getCompilationSettings: () => options,
getCurrentDirectory: () => dirname(model),
getDefaultLibFileName: ts.getDefaultLibFilePath
});
let service: NativeLanguageService;
beforeAll(async () => {
service = new NativeLanguageService();
await service.open(dirname(model), sources);
});
afterAll(async () => {
await service?.close();
});
afterAll(() => service?.dispose());

test('navigation fixtures typecheck against public package declarations', () => {
// Formatting must not silently stop the marker parser from finding cases.
expect(references.length).toBeGreaterThanOrEqual(64);
expect(new Set(references.map(reference => reference.name))).toEqual(
new Set(Object.keys(documentation))
);
const diagnostics = [...sources.keys()].flatMap(file => [
...service.getSyntacticDiagnostics(file),
...service.getSemanticDiagnostics(file)
]);
expect(
diagnostics.map(d =>
ts.flattenDiagnosticMessageText(d.messageText, '\n')
)
).toEqual([]);
const files = service.getProgram()!.getSourceFiles();
const result = spawnSync(
process.execPath,
[
typescriptCli(),
'-p',
fileURLToPath(
new URL('../test-fixtures/tsconfig.json', import.meta.url)
),
'--listFiles',
'--pretty',
'false'
],
{ encoding: 'utf8', timeout: 120_000 }
);
expect(result.error).toBeUndefined();
expect(result.status, result.stdout + result.stderr).toBe(0);
const files = result.stdout.split('\n').map(file => file.trim());
for (const name of [
'knex-schema',
'orm',
Expand All @@ -154,9 +146,7 @@ test('navigation fixtures typecheck against public package declarations', () =>
'client'
]) {
expect(
files.some(file =>
file.fileName.endsWith(`/libs/${name}/dist/index.d.ts`)
),
files.some(file => file.endsWith(`/libs/${name}/dist/index.d.ts`)),
`${name} must be tested through its published declarations`
).toBe(true);
}
Expand All @@ -165,33 +155,40 @@ test('navigation fixtures typecheck against public package declarations', () =>
}, 120_000);

describe.each(references)('$language $name ($property)', reference => {
test('goes to the original property declaration', () => {
test('goes to the original property declaration', async () => {
const doc = documentation[reference.name];
expect(doc).toBeDefined();
const comment = `/** ${doc} */`;
const offset = modelSource.indexOf(comment);
expect(offset).toBeGreaterThanOrEqual(0);
const scanner = ts.createScanner(
ts.ScriptTarget.ES2022,
const scanner = createScanner(
true,
ts.LanguageVariant.Standard,
LanguageVariant.Standard,
modelSource
);
scanner.setTextPos(offset + comment.length);
scanner.resetTokenState(offset + comment.length);
scanner.scan();
if (scanner.getToken() === ts.SyntaxKind.ReadonlyKeyword)
scanner.scan();
if (scanner.getToken() === SyntaxKind.ReadonlyKeyword) scanner.scan();
expect(scanner.getTokenText()).toBe(reference.property);
const definitions = service.getDefinitionAtPosition(
reference.file,
reference.position
const definitions = await service.request<Location[]>(
'textDocument/definition',
{
textDocument: { uri: pathToFileURL(reference.file).href },
position: positionAt(
sources.get(reference.file)!,
reference.position
)
}
);
expect(definitions).toContainEqual(
expect.objectContaining({
fileName: model,
textSpan: {
start: scanner.getTokenPos(),
length: reference.property.length
uri: pathToFileURL(model).href,
range: {
start: positionAt(modelSource, scanner.getTokenStart()),
end: positionAt(
modelSource,
scanner.getTokenStart() + reference.property.length
)
}
})
);
Expand All @@ -200,14 +197,21 @@ describe.each(references)('$language $name ($property)', reference => {
// TypeScript omits hover docs for this union-context object literal even
// when both branches navigate to the same schema. Keep the accepted union.
if (reference.name !== 'update-key') {
test('shows the original JSDoc on hover', () => {
const info = service.getQuickInfoAtPosition(
reference.file,
reference.position
);
expect(ts.displayPartsToString(info?.documentation)).toBe(
documentation[reference.name]
test('shows the original JSDoc on hover', async () => {
const info = await service.request<{ contents: { value: string } }>(
'textDocument/hover',
{
textDocument: { uri: pathToFileURL(reference.file).href },
position: positionAt(
sources.get(reference.file)!,
reference.position
)
}
);
// Markdown hover separates the signature from the original documentation.
expect(
info.contents.value.replace(/^```[\s\S]*?```\s*/, '').trim()
).toBe(documentation[reference.name]);
});
}
});
53 changes: 31 additions & 22 deletions libs/knex-schema/src/public-api-docs.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import { describe, expect, it } from 'vitest';
import type { Node, SourceFile } from 'typescript/unstable/ast';
import { ModifierFlags } from 'typescript/unstable/ast';
import * as ts from 'typescript/unstable/ast/is';
import { getCombinedModifierFlags } from 'typescript/unstable/ast/utils';
import { API } from 'typescript/unstable/sync';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';

let api: API;
beforeAll(() => {
api = new API();
});
afterAll(() => api?.close());

/** Inspect emitted declarations, since those are what installed consumers read. */
function declaration(path: string): ts.SourceFile {
function declaration(path: string): SourceFile {
const url = new URL(path, import.meta.url);
return ts.createSourceFile(
fileURLToPath(url),
readFileSync(url, 'utf8'),
ts.ScriptTarget.Latest,
true
);
return api.createSourceFile(fileURLToPath(url), readFileSync(url, 'utf8'))
.sourceFile;
}

function hasSummary(node: ts.Node): boolean {
const docs = (node as ts.Node & { jsDoc?: readonly ts.JSDoc[] }).jsDoc;
return !!docs?.some(doc => doc.comment);
function hasSummary(node: Node): boolean {
return !!node.jsDoc?.some(doc => ts.isJSDoc(doc) && doc.comment.length);
}

function isInternal(node: ts.Node): boolean {
return ts.getJSDocTags(node).some(tag => tag.tagName.text === 'internal');
function isInternal(node: Node): boolean {
return !!node.jsDoc?.some(
doc =>
ts.isJSDoc(doc) &&
doc.tags?.some(tag => tag.tagName.getText() === 'internal')
);
}

describe('published query/ORM API documentation', () => {
Expand Down Expand Up @@ -60,7 +69,7 @@ describe('published query/ORM API documentation', () => {
);
expect(missing).toEqual([]);

function check(source: ts.SourceFile, names: Set<string>): void {
function check(source: SourceFile, names: Set<string>): void {
for (const node of source.statements) {
if (
ts.isVariableStatement(node) &&
Expand All @@ -87,9 +96,9 @@ describe('published query/ORM API documentation', () => {
if (ts.isClassDeclaration(node)) {
for (const member of node.members) {
if (
ts.getCombinedModifierFlags(member) &
(ts.ModifierFlags.Private |
ts.ModifierFlags.Protected) ||
getCombinedModifierFlags(member) &
(ModifierFlags.Private |
ModifierFlags.Protected) ||
(member.name &&
ts.isPrivateIdentifier(member.name)) ||
isInternal(member)
Expand Down Expand Up @@ -117,17 +126,17 @@ describe('published query/ORM API documentation', () => {
]) {
const source = declaration(path);
const missing: string[] = [];
function visit(node: ts.Node): void {
function visit(node: Node): void {
if (
(ts.isMethodSignature(node) ||
ts.isPropertySignature(node) ||
(ts.isMethodSignatureDeclaration(node) ||
ts.isPropertySignatureDeclaration(node) ||
ts.isCallSignatureDeclaration(node)) &&
!isInternal(node) &&
!hasSummary(node)
) {
missing.push(node.getText(source).split('\n')[0]);
}
ts.forEachChild(node, visit);
node.forEachChild(visit);
}
const node = source.statements.find(
n =>
Expand Down
Loading
Loading