diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 0c7836d..bec3e1b 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -1,44 +1,35 @@ name: Set up the pinned toolchain -description: Install the Node, npm, Bun and sqlc versions pinned by verification/compatibility.json, then install project dependencies. +description: Install the pinned Node, npm and Bun, optionally a requested sqlc, then install project dependencies. inputs: bun: description: Install the pinned Bun when "true". default: "false" sqlc: - description: Install sqlc — "ceiling" for the tested ceiling, or an explicit version such as 1.24.0. Empty installs none. + description: Install this sqlc version, such as 1.31.1. Empty installs none. default: "" -outputs: - sqlc-matrix: - description: The sqlc sample matrix as JSON. - value: ${{ steps.pins.outputs.sqlc-matrix }} - runs: using: composite steps: - - id: pins - shell: bash - run: node scripts/workflows/pins.mjs - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: ${{ steps.pins.outputs.node }} + node-version: 24.12.0 cache: npm - if: inputs.bun == 'true' uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: - bun-version: ${{ steps.pins.outputs.bun }} + bun-version: 1.3.10 - if: inputs.sqlc != '' uses: sqlc-dev/setup-sqlc@6bd2de0e87f5adfd968b55f140ce55461f71db69 # v4 with: - sqlc-version: ${{ inputs.sqlc == 'ceiling' && steps.pins.outputs.sqlc-ceiling || inputs.sqlc }} + sqlc-version: ${{ inputs.sqlc }} - shell: bash run: | - npm install --global "npm@${{ steps.pins.outputs.npm }}" + npm install --global "npm@11.6.2" npm ci echo "==> Toolchain on this runner:" echo " node $(node --version)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28daf44..8668048 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,15 +12,12 @@ jobs: verify: name: Verify runs-on: ubuntu-latest - outputs: - sqlc-matrix: ${{ steps.setup.outputs.sqlc-matrix }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - - id: setup - uses: ./.github/actions/setup + - uses: ./.github/actions/setup with: bun: "true" - sqlc: ceiling + sqlc: 1.31.1 - name: Check formatting (run make fmt to fix) run: make fmt-check @@ -35,26 +32,26 @@ jobs: if-no-files-found: error sqlc-compatibility: - name: sqlc ${{ matrix.sqlc.version }} + name: sqlc ${{ matrix.sqlc }} needs: verify runs-on: ubuntu-latest strategy: fail-fast: false matrix: - sqlc: ${{ fromJSON(needs.verify.outputs.sqlc-matrix) }} + sqlc: ["1.25.0", "1.31.1"] steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - uses: ./.github/actions/setup with: - sqlc: ${{ matrix.sqlc.install }} + sqlc: ${{ matrix.sqlc }} - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: plugin-wasm path: build - - name: Generate and type-check the fixtures with sqlc ${{ matrix.sqlc.version }} + - name: Generate and type-check the fixtures with sqlc ${{ matrix.sqlc }} run: | node scripts/verify-sqlc-compatibility.ts \ --candidate "$PWD/build/plugin.wasm" \ - --sqlc-version "${{ matrix.sqlc.version }}" \ + --sqlc-version "${{ matrix.sqlc }}" \ --sqlc "$(command -v sqlc)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e4f599..3742950 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,6 @@ jobs: name: Verify runs-on: ubuntu-latest outputs: - sqlc-matrix: ${{ steps.setup.outputs.sqlc-matrix }} version: ${{ steps.intent.outputs.version }} tag: ${{ steps.intent.outputs.tag }} source-commit: ${{ steps.intent.outputs.source-commit }} @@ -31,11 +30,10 @@ jobs: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: fetch-depth: 0 - - id: setup - uses: ./.github/actions/setup + - uses: ./.github/actions/setup with: bun: "true" - sqlc: ceiling + sqlc: 1.31.1 - name: Validate the release identity before building anything id: intent @@ -62,28 +60,28 @@ jobs: if-no-files-found: error sqlc-compatibility: - name: sqlc ${{ matrix.sqlc.version }} + name: sqlc ${{ matrix.sqlc }} needs: verify runs-on: ubuntu-latest strategy: fail-fast: false matrix: - sqlc: ${{ fromJSON(needs.verify.outputs.sqlc-matrix) }} + sqlc: ["1.25.0", "1.31.1"] steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - uses: ./.github/actions/setup with: - sqlc: ${{ matrix.sqlc.install }} + sqlc: ${{ matrix.sqlc }} - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: plugin-wasm path: build - - name: Generate and type-check the fixtures with sqlc ${{ matrix.sqlc.version }} + - name: Generate and type-check the fixtures with sqlc ${{ matrix.sqlc }} run: | node scripts/verify-sqlc-compatibility.ts \ --candidate "$PWD/build/plugin.wasm" \ - --sqlc-version "${{ matrix.sqlc.version }}" \ + --sqlc-version "${{ matrix.sqlc }}" \ --sqlc "$(command -v sqlc)" # The only job that writes anything outside this run. R2 first, because the release diff --git a/AGENTS.md b/AGENTS.md index 8966176..9ed64f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ A sqlc code-generation plugin that emits TypeScript executed through a Cloudflar Prettier owns formatting. Run `make fmt` before committing; `make fmt-check` gates CI. -Everything in `scripts/` is TypeScript run directly by Node (`node scripts/foo.ts`) — Node strips the types, so those files must stay within erasable syntax (no `enum`, no `namespace`, no constructor parameter properties) and must use explicit `.ts` extensions on relative imports. The same applies to `src/` and `test/`, because the tests run those sources directly. `scripts/workflows/pins.mjs` is the one exception: it chooses the Node version, so it runs before the toolchain is pinned and must stay plain JavaScript. +Everything in `scripts/` is TypeScript run directly by Node (`node scripts/foo.ts`) — Node strips the types, so those files must stay within erasable syntax (no `enum`, no `namespace`, no constructor parameter properties) and must use explicit `.ts` extensions on relative imports. The same applies to `src/` and `test/`, because the tests run those sources directly. ## The generator and the shipped runtime (`src/`) @@ -13,6 +13,8 @@ Everything in `scripts/` is TypeScript run directly by Node (`node scripts/foo.t | `src/app.ts` | Javy entry point: read stdin, write stdout and stderr, throw on failure. | | `src/plugin.ts` | Decode the generate request, run validation and generation, render diagnostics. The one place a failure becomes stderr text. | | `src/validation.ts` | Protocol, options, and query boundary validation. `SUPPORTED_COMMANDS` is the command surface. | +| `src/compatibility.ts` | The supported sqlc floor and the tested ceiling, as two literals and nothing else. | +| `src/semver.ts` | SemVer parsing and precedence, shared with `scripts/release.ts`. Carries build metadata; never compares it. | | `src/diagnostics.ts` | `[CATEGORY/REASON]` identifiers, severity, and redaction of SQL and values. | | `src/emission-plan.ts` | Naming, collision avoidance, argument and row field plans, per-command result shapes. | | `src/embeds.ts` | `sqlc.embed` reconstruction and private alias rewriting. | @@ -43,13 +45,13 @@ Native D1 errors pass through unwrapped, so a consumer can recognize them. A `Qu `make verify-local` builds the plugin once and runs everything that needs no credentials. The layers each answer a different question about the same build: -| Target | Question | -| --------------------- | ---------------------------------------------------------------------------------------------------------- | -| `make test-unit` | Pure request-to-file generator behaviour, plus the script contracts. | -| `make test-candidate` | The same scenarios through the real wasm, and the public type surface on the floor and current TypeScript. | -| `make test-drift` | Does the committed generated output still match what the plugin emits? | -| `make test-miniflare` | Real workerd and D1 storage, fresh per test. | -| `make test-example` | The canonical Worker still builds and passes. | +| Target | Question | +| --------------------- | --------------------------------------------------------------------------------------- | +| `make test-unit` | Pure request-to-file generator behaviour, plus the script contracts. | +| `make test-candidate` | The same scenarios through the real wasm, and the public type surface under TypeScript. | +| `make test-drift` | Does the committed generated output still match what the plugin emits? | +| `make test-miniflare` | Real workerd and D1 storage, fresh per test. | +| `make test-example` | The canonical Worker still builds and passes. | Tests run straight from TypeScript (`node --test`), and the unit target globs `test/*.test.ts test/generator/*.test.ts` — adding a test file needs no list edited anywhere. @@ -68,9 +70,11 @@ done **Sub-project formatting.** `examples/d1-worker/` and `test/miniflare/` are bun sub-projects that keep their own Prettier configuration; Prettier resolves configuration per file, so their hand-written sources stay tab-indented. Use bun inside those directories. -**Compatibility configuration.** `verification/compatibility.json` pins the sqlc samples and their rationale, the known exceptions, the TypeScript floor and current versions, and the Node/npm/Bun versions CI installs. It holds only what is not already recorded elsewhere — the Cloudflare versions live in the fixtures' `bun.lock` and `wrangler.jsonc`, and the buf and javy pins live in their install scripts. `scripts/workflows/pins.mjs` feeds the workflows from it and `docs/compatibility.md` presents it to consumers. +**The supported sqlc range is two literals.** `src/compatibility.ts` holds the supported floor and the tested ceiling; validation enforces the floor, `docs/compatibility.md` presents both to consumers, and the CI matrix runs exactly those two versions. Every other pinned version lives with the thing it pins — Node, npm and Bun in `.github/actions/setup/action.yml`, the Cloudflare versions in the fixtures' `bun.lock` and `wrangler.jsonc`, the buf and javy pins in their install scripts. -**Decided, against the obvious default:** sqlc is sampled strategically — the floor, the tested ceiling, and the intervening releases tied to a material protocol or metadata change. A matrix over every sqlc minor was considered and rejected ([#13](https://github.com/mkuznets/sqlc-d1-typescript/issues/13), [#38](https://github.com/mkuznets/sqlc-d1-typescript/issues/38)). +**The floor cell does not run on macOS.** `make test-sqlc-compatibility SQLC_VERSION=1.25.0` dies with `SIGKILL` on macOS arm64: that sqlc's wasm runtime cannot execute the plugin there, though the binary itself runs. It passes on the Linux x64 CI runner, which is where that cell is meant to run. Reproduce it locally with Docker rather than concluding the plugin is broken. + +**Decided, against the obvious default:** the matrix samples only the floor and the ceiling. A matrix over every sqlc minor, and a matrix with intervening samples, were both considered and rejected ([#13](https://github.com/mkuznets/sqlc-d1-typescript/issues/13), [#38](https://github.com/mkuznets/sqlc-d1-typescript/issues/38)). ## Release @@ -78,7 +82,7 @@ done **A valid tag is the approval.** A strict SemVer `v*` tag on default-branch lineage is the maintainer's release approval, and it is the only one. There is no dry-run mode: to rehearse a release, cut the next patch version. -**Publication order is the safety property.** R2 first, then the public origin is re-downloaded and compared, then the GitHub Release is cut — the release notes may only advertise a URL that already serves the right bytes. The R2 write uses `--if-none-match '*'`, so a version key can never be replaced once published. `contents: write` appears on the `publish` job and nowhere else, and `test/verification-contracts.test.ts` holds the workflow to that. Operations live in `docs/release-publication.md`. +**Publication order is the safety property.** R2 first, then the public origin is re-downloaded and compared, then the GitHub Release is cut — the release notes may only advertise a URL that already serves the right bytes. The R2 write uses `--if-none-match '*'`, so a version key can never be replaced once published. `contents: write` appears on the `publish` job and nowhere else. Operations live in `docs/release-publication.md`. **Canonical names.** `sqlc-gen-d1-typescript_.wasm` and `sqlc-gen-d1-typescript_.manifest.json`, with no aliases. The URL shape and the manifest contract live in `scripts/release.ts`. diff --git a/README.md b/README.md index cd16be8..9033a9e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ sql: interface: workers ``` -The SHA-256 is part of the selected-release configuration, not a placeholder to omit. The release manifest binds the version, tag, source commit, artifact size, URL, and digest to the same published artifact, alongside the tool versions it was tested against. +The SHA-256 is part of the selected-release configuration, not a placeholder to omit. The release manifest binds the version, tag, source commit, artifact size, URL, and digest to the same published artifact, alongside the sqlc versions it was tested against. ## Generate your first query diff --git a/docs/compatibility.md b/docs/compatibility.md index 44725a4..ff57b83 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -1,31 +1,16 @@ # Compatibility -The source of truth is [`verification/compatibility.json`](../verification/compatibility.json). Everything on this page is derived from it. - ## sqlc support policy -- Supported floor: **v1.18.0**. -- Tested ceiling: **v1.31.1**. +- Supported floor: **v1.25.0**. Older versions are rejected with `[COMPATIBILITY/UNSUPPORTED_SQLC_VERSION]`. +- Tested ceiling: **v1.31.1**. Every CI run generates and type-checks the repository's fixtures with both the floor and the ceiling. - Versions newer than the ceiling are not rejected. They produce `[COMPATIBILITY/UNTESTED_SQLC_VERSION]` and continue generation unless another incompatibility is found. -- sqlc is sampled strategically: the floor, the tested ceiling, and the intervening releases tied to a material protocol or metadata change. Every sample below is tested on every CI run; this is not a claim that each intervening release has its own cell. - -| sqlc | Role | Why this sample | -| ------- | ----------- | -------------------------------------------------------- | -| v1.18.0 | floor | Oldest supported Plugin protocol baseline. | -| v1.20.0 | intervening | Includes the material `sqlc.slice` generation fix. | -| v1.24.0 | intervening | Refactors the Plugin interface around `GenerateRequest`. | -| v1.31.1 | ceiling | Newest release verified by the compatibility suite. | - -Known exceptions: - -1. sqlc v1.18.0 cannot parse the current `sqlc.arg`, `sqlc.narg`, `sqlc.slice`, or `sqlc.embed` fixture syntax; its matrix cell uses `test/sqlc-v1-18` to cover all six ordinary commands and positional binds. -2. sqlc v1.20.0 and v1.24.0 parse the current fixture syntax, but their legacy Plugin WASM runtimes cannot execute the plugin from the official release binaries on macOS arm64; their Ubuntu x64 CI cells run both current fixture corpora. See [sqlc-to-D1 translation](sqlc-to-d1.md) for the commands, macros, metadata, and value representations in the compatibility surface. ## TypeScript -Generated code compiles under both the supported floor compiler (**5.2.2**) and the current one (**5.9.3**). Consumers need neither exact version; the floor is the oldest compiler the emitted types are known to satisfy. +Generated code is strict-mode TypeScript, compiled and verified against a current TypeScript on every CI run. No particular compiler version is required of consumers. ## Cloudflare @@ -33,7 +18,7 @@ Local verification runs the plugin's output under Miniflare and workerd, at the ## What a release binds -Each GitHub release ships the WASM plus a `sqlc-gen-d1-typescript_.manifest.json` recording the version, tag, source commit, the artifact's permanent URL, its lowercase SHA-256 and size, and the tool versions it was tested against. +Each GitHub release ships the WASM plus a `sqlc-gen-d1-typescript_.manifest.json` recording the version, tag, source commit, the artifact's permanent URL, its lowercase SHA-256 and size, and the sqlc floor and ceiling it was tested against. Before the release is cut, the artifact is written to its permanent R2 key with a create-only conditional write, then re-downloaded from the public URL unauthenticated and compared to the bytes just published. A release you can see is one whose artifact was already proven to serve at that digest. diff --git a/docs/release-publication.md b/docs/release-publication.md index 4b61246..1aba4c7 100644 --- a/docs/release-publication.md +++ b/docs/release-publication.md @@ -8,7 +8,7 @@ Everything below is executed by the `publish` job in [`.github/workflows/release The `verify` job validates the tag, builds the plugin once, and runs every uncredentialed check against it. The `sqlc-compatibility` matrix tests those same bytes across the sampled sqlc versions. Only then does `publish` run: -1. **Manifest** — the wasm is renamed to its canonical filename and `scripts/release.ts manifest` records its digest, size, permanent URL, and the tested tool versions. +1. **Manifest** — the wasm is renamed to its canonical filename and `scripts/release.ts manifest` records its digest, size, permanent URL, and the tested sqlc range. 2. **Version key** — `aws s3api put-object` with `--if-none-match '*'` and `--content-md5`. The conditional write is what makes the key immutable: if it already holds bytes, R2 answers `412` and the step fails rather than replacing what is already advertised. 3. **Public verification** — the public URL is fetched unauthenticated, exactly as a consumer does, and its SHA-256 compared to the bytes just published. The release notes may only advertise a URL that already serves the right bytes. 4. **Publish** — `gh release create` attaches the wasm and its manifest. This is the last write of the run. @@ -31,7 +31,7 @@ Configure these identifiers (never commit their values): The `v*` rule must be created as a **tag** rule; created as a branch rule it matches no tag, and every tag push is then refused the Environment. -GitHub credentials are the job-scoped `GITHUB_TOKEN` only. `contents: write` appears on the publish job and nowhere else; `test/verification-contracts.test.ts` holds the workflow to that. +GitHub credentials are the job-scoped `GITHUB_TOKEN` only. `contents: write` appears on the publish job and nowhere else. ## How R2 is reached diff --git a/docs/sqlc-to-d1.md b/docs/sqlc-to-d1.md index 64d705e..ba96b39 100644 --- a/docs/sqlc-to-d1.md +++ b/docs/sqlc-to-d1.md @@ -70,4 +70,4 @@ Generation stops instead of guessing when analyzed metadata cannot be translated - duplicate physical row keys without unique SQL aliases; - unsafe/colliding output paths or TypeScript declarations. -A sqlc version newer than the tested ceiling is different: `[COMPATIBILITY/UNTESTED_SQLC_VERSION]` is a warning and generation continues unless another incompatibility exists. See [compatibility](compatibility.md) for the current floor and tested samples, [troubleshooting](troubleshooting.md) for corrections, and [runtime and errors](runtime-and-errors.md) for execution behavior. +A sqlc version newer than the tested ceiling is different: `[COMPATIBILITY/UNTESTED_SQLC_VERSION]` is a warning and generation continues unless another incompatibility exists. See [compatibility](compatibility.md) for the current floor and tested ceiling, [troubleshooting](troubleshooting.md) for corrections, and [runtime and errors](runtime-and-errors.md) for execution behavior. diff --git a/package-lock.json b/package-lock.json index a0155d0..9a71900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,7 @@ "@types/node": "^24.13.3", "esbuild": "^0.27.3", "prettier": "3.9.6", - "typescript": "^5.2.2", - "typescript-5-2": "npm:typescript@5.2.2" + "typescript": "^5.9.3" } }, "node_modules/@bufbuild/protobuf": { @@ -563,21 +562,6 @@ "node": ">=14.17" } }, - "node_modules/typescript-5-2": { - "name": "typescript", - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", diff --git a/package.json b/package.json index 4eebb45..b652798 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ "@types/node": "^24.13.3", "esbuild": "^0.27.3", "prettier": "3.9.6", - "typescript": "^5.2.2", - "typescript-5-2": "npm:typescript@5.2.2" + "typescript": "^5.9.3" }, "dependencies": { "@bufbuild/protobuf": "^1.4.2", diff --git a/scripts/compatibility-config.ts b/scripts/compatibility-config.ts deleted file mode 100644 index 2a5e114..0000000 --- a/scripts/compatibility-config.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; - -const CONFIG_PATH = "verification/compatibility.json"; - -export type SqlcRole = "floor" | "intervening" | "ceiling"; - -export interface CompatibilityConfig { - readonly sqlc: { - readonly supportedFloor: string; - readonly testedCeiling: string; - readonly samples: readonly { readonly version: string; readonly role: SqlcRole; readonly rationale: string }[]; - readonly knownExceptions: readonly string[]; - }; - readonly typescript: { readonly floor: string; readonly current: string }; - readonly tools: { readonly node: string; readonly npm: string; readonly bun: string }; -} - -const stripV = (value: string): string => (value.startsWith("v") ? value.slice(1) : value); - -function compareVersions(left: string, right: string): number { - const a = stripV(left).split(".").map(Number); - const b = stripV(right).split(".").map(Number); - for (let index = 0; index < Math.max(a.length, b.length); index++) { - const difference = (a[index] ?? 0) - (b[index] ?? 0); - if (difference) return Math.sign(difference); - } - return 0; -} - -function fail(path: string, message: string): never { - throw new Error(`compatibility.${path}: ${message}`); -} - -// The config is a checked-in file this repo owns, so its shape is owned by -// CompatibilityConfig above. This guards the handful of typos a hand edit actually -// produces; validateSemantics does the checking that matters. -function assertShape(config: unknown): asserts config is CompatibilityConfig { - const present = (path: string, value: unknown): void => { - if (value === undefined || value === null) fail(path, "is missing"); - }; - if (typeof config !== "object" || config === null) fail("json", "must be an object"); - const value = config as Record | undefined>; - for (const section of ["sqlc", "typescript", "tools"]) present(section, value[section]); - for (const field of ["supportedFloor", "testedCeiling", "samples", "knownExceptions"]) - present(`sqlc.${field}`, value.sqlc?.[field]); - for (const field of ["floor", "current"]) present(`typescript.${field}`, value.typescript?.[field]); - for (const field of ["node", "npm", "bun"]) present(`tools.${field}`, value.tools?.[field]); - if (!Array.isArray(value.sqlc?.samples) || value.sqlc.samples.length === 0) - fail("sqlc.samples", "must be a non-empty array"); -} - -function validateSemantics(config: CompatibilityConfig): void { - const samples = config.sqlc.samples; - const versions = samples.map(({ version }) => stripV(version)); - if (new Set(versions).size !== versions.length) fail("sqlc.samples", "versions must be unique"); - if (samples.filter(({ role }) => role === "floor").length !== 1) - fail("sqlc.samples", "must contain exactly one floor role"); - if (samples.filter(({ role }) => role === "ceiling").length !== 1) - fail("sqlc.samples", "must contain exactly one ceiling role"); - const last = samples[samples.length - 1]; - if (samples[0].role !== "floor" || last?.role !== "ceiling") - fail("sqlc.samples", "floor and ceiling must be the first and last samples"); - if (stripV(config.sqlc.supportedFloor) !== versions[0]) - fail("sqlc.supportedFloor", `must equal first sample ${samples[0].version}`); - if (stripV(config.sqlc.testedCeiling) !== versions[versions.length - 1]) - fail("sqlc.testedCeiling", `must equal last sample ${last?.version}`); - for (let index = 1; index < versions.length; index++) - if (compareVersions(versions[index - 1], versions[index]) >= 0) - fail(`sqlc.samples[${index}].version`, "samples must be strictly ordered"); -} - -export async function loadCompatibilityConfig({ - root = process.cwd(), -}: { root?: string } = {}): Promise { - const source = await readFile(resolve(root, CONFIG_PATH), "utf8"); - let config: unknown; - try { - config = JSON.parse(source); - } catch (error) { - fail("json", error instanceof Error ? error.message : String(error)); - } - assertShape(config); - validateSemantics(config); - return config; -} diff --git a/scripts/release.ts b/scripts/release.ts index e8c3684..8b5b5db 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -7,10 +7,9 @@ import { appendFile, readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { spawn } from "node:child_process"; import { parseArguments, runAsCli, usageError } from "./candidate-utils.ts"; -import { loadCompatibilityConfig, type CompatibilityConfig } from "./compatibility-config.ts"; +import { MINIMUM_SQLC_VERSION, TESTED_SQLC_VERSION } from "../src/compatibility.ts"; +import { parseSemVer } from "../src/semver.ts"; -const SEMVER = - /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$/; const SOURCE_SHA = /^[0-9a-f]{40}$/; const PUBLIC_ORIGIN = "https://sqlc.mkuznets.com/plugins"; @@ -25,7 +24,7 @@ export interface ReleaseManifest { artifact: { filename: string; sha256: string; size: number; url: string }; source_commit: string; tag: string; - tested_versions: { bun: string; node: string; npm: string; sqlc: string[]; typescript: string[] }; + tested_versions: { sqlc: { floor: string; ceiling: string } }; version: string; workflow_url: string; } @@ -35,7 +34,11 @@ export function parseSemver(value: unknown, { prefixed = false }: { prefixed?: b if (typeof value !== "string" || (prefixed ? !value.startsWith("v") : value.startsWith("v"))) throw usageError(`rejected version ${JSON.stringify(value)}; expected ${shape}`); const version = prefixed ? value.slice(1) : value; - if (!SEMVER.test(version)) throw usageError(`rejected version ${JSON.stringify(value)}; expected ${shape}`); + // The shared parser takes an optional `v` and keeps build metadata; a release identity + // allows neither, because two tags would otherwise name the same published artifact. + const parsed = version.startsWith("v") ? undefined : parseSemVer(version); + if (!parsed || parsed.build.length > 0) + throw usageError(`rejected version ${JSON.stringify(value)}; expected ${shape}`); return version; } @@ -105,11 +108,9 @@ export async function resolveReleaseIntent({ export function createReleaseManifest({ intent, bytes, - config, }: { intent: ReleaseIntent; bytes: Uint8Array; - config: CompatibilityConfig; }): ReleaseManifest { const filename = canonicalWasmFilename(intent.version); if (parseSemver(intent.tag, { prefixed: true }) !== intent.version) @@ -123,13 +124,7 @@ export function createReleaseManifest({ }, source_commit: intent.sourceCommit, tag: intent.tag, - tested_versions: { - bun: config.tools.bun, - node: config.tools.node, - npm: config.tools.npm, - sqlc: config.sqlc.samples.map(({ version }) => version), - typescript: [config.typescript.floor, config.typescript.current], - }, + tested_versions: { sqlc: { floor: MINIMUM_SQLC_VERSION, ceiling: TESTED_SQLC_VERSION } }, version: intent.version, workflow_url: intent.workflowUrl, }; @@ -200,7 +195,6 @@ async function cli(): Promise { workflowUrl: values["workflow-url"], }, bytes, - config: await loadCompatibilityConfig(), }); const expected = canonicalManifestFilename(manifest.version); if (!values.output.endsWith(expected)) throw usageError(`manifest filename must be ${expected}`); diff --git a/scripts/verify-sqlc-compatibility.ts b/scripts/verify-sqlc-compatibility.ts index a50dd12..6a2e328 100644 --- a/scripts/verify-sqlc-compatibility.ts +++ b/scripts/verify-sqlc-compatibility.ts @@ -3,9 +3,8 @@ import { spawn } from "node:child_process"; import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { readCandidate, parseArguments, runAsCli, usageError } from "./candidate-utils.ts"; -import { fixtures, clearGeneratedDirectory, type Fixture } from "./check-generated-drift.ts"; +import { fixtures, clearGeneratedDirectory } from "./check-generated-drift.ts"; import { generateCandidate } from "./generate-candidate.ts"; -import { loadCompatibilityConfig } from "./compatibility-config.ts"; function capture(command: string, args: readonly string[], cwd: string): Promise { return new Promise((ok, fail) => { @@ -31,23 +30,7 @@ function run(command: string, args: readonly string[], cwd: string): Promise (error instanceof Error ? error.message : String(error)); -const floorFixture: Fixture = { - directory: "test/sqlc-v1-18", - config: "sqlc.yaml", - generatedDirectory: "src", - staticFiles: [], -}; - -// sqlc v1.18.0 cannot parse the current fixture syntax, so the floor cell runs its own -// corpus. Every other sample runs the fixtures the repository actually ships. -export function fixturesForSqlcVersion(version: string): readonly Fixture[] { - return version === "v1.18.0" ? [floorFixture] : fixtures; -} - -const normalizeVersion = (text: string): string | undefined => { - const match = text.match(/v?(\d+\.\d+\.\d+)/); - return match ? `v${match[1]}` : undefined; -}; +const normalizeVersion = (text: string): string | undefined => text.match(/v?(\d+\.\d+\.\d+)/)?.[1]; export async function verifySqlcCompatibility({ candidate, @@ -61,9 +44,6 @@ export async function verifySqlcCompatibility({ root?: string; }): Promise { const retained = await readCandidate(candidate); - const config = await loadCompatibilityConfig({ root }); - if (!config.sqlc.samples.some(({ version }) => version === sqlcVersion)) - throw usageError(`sqlc version ${sqlcVersion} is not listed in compatibility.sqlc.samples`); const actual = normalizeVersion(await capture(sqlc, ["version"], root)); if (actual !== sqlcVersion) throw usageError( @@ -74,7 +54,7 @@ export async function verifySqlcCompatibility({ await mkdir(cacheRoot, { recursive: true }); const mirror = await mkdtemp(resolve(cacheRoot, "sqlc-compatibility-")); try { - for (const fixture of fixturesForSqlcVersion(sqlcVersion)) { + for (const fixture of fixtures) { const destination = resolve(mirror, fixture.directory); await cp(resolve(root, fixture.directory), destination, { recursive: true, @@ -100,9 +80,7 @@ export async function verifySqlcCompatibility({ destination, ); } catch (error) { - throw new Error( - `${sqlcVersion} compile failed for ${fixture.directory} with TypeScript ${config.typescript.current}: ${describe(error)}`, - ); + throw new Error(`${sqlcVersion} compile failed for ${fixture.directory} with TypeScript: ${describe(error)}`); } } } finally { diff --git a/scripts/workflows/pins.mjs b/scripts/workflows/pins.mjs deleted file mode 100644 index 674ccd8..0000000 --- a/scripts/workflows/pins.mjs +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node -// Publish the toolchain pins from verification/compatibility.json as step outputs, so -// no workflow hardcodes a version that is pinned there. -// -// Plain JavaScript on purpose: this runs before setup-node has pinned the runner's -// Node, so it cannot rely on TypeScript type stripping. -import { appendFileSync, readFileSync } from "node:fs"; - -const config = JSON.parse(readFileSync("verification/compatibility.json", "utf8")); -const samples = config.sqlc.samples.map(({ version }) => ({ version, install: version.replace(/^v/, "") })); - -const outputs = { - node: config.tools.node, - npm: config.tools.npm, - bun: config.tools.bun, - "sqlc-ceiling": config.sqlc.testedCeiling.replace(/^v/, ""), - "sqlc-matrix": JSON.stringify(samples), -}; - -console.log("==> Toolchain pinned by verification/compatibility.json"); -for (const [key, value] of Object.entries(outputs)) console.log(` ${key}=${value}`); - -if (process.env.GITHUB_OUTPUT) { - appendFileSync( - process.env.GITHUB_OUTPUT, - Object.entries(outputs) - .map(([key, value]) => `${key}=${value}\n`) - .join(""), - ); -} diff --git a/src/compatibility.ts b/src/compatibility.ts new file mode 100644 index 0000000..7e7f9e8 --- /dev/null +++ b/src/compatibility.ts @@ -0,0 +1,4 @@ +// The sqlc versions this plugin supports. The floor is enforced by validation; the +// ceiling is the newest version CI proves against. +export const MINIMUM_SQLC_VERSION = "1.25.0"; +export const TESTED_SQLC_VERSION = "1.31.1"; diff --git a/src/semver.ts b/src/semver.ts new file mode 100644 index 0000000..6855f64 --- /dev/null +++ b/src/semver.ts @@ -0,0 +1,59 @@ +// SemVer parsing and precedence, shared by the plugin's sqlc-version check and the +// release identity check. Build metadata is parsed and carried but never affects +// precedence, so a caller that must reject it can see that it was there. + +export interface SemVer { + core: [string, string, string]; + prerelease: string[]; + build: string[]; +} + +export function parseSemVer(value: string): SemVer | undefined { + const match = + /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec( + value, + ); + if (!match) return undefined; + const prerelease = match[4]?.split(".") ?? []; + if (prerelease.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0"))) + return undefined; + return { core: [match[1], match[2], match[3]], prerelease, build: match[5]?.split(".") ?? [] }; +} + +export function compareSemVer(left: SemVer, right: SemVer): number { + for (let index = 0; index < 3; index++) { + const comparison = compareNumericText(left.core[index], right.core[index]); + if (comparison !== 0) return comparison; + } + + if (left.prerelease.length === 0 || right.prerelease.length === 0) { + return left.prerelease.length === right.prerelease.length ? 0 : left.prerelease.length === 0 ? 1 : -1; + } + + const length = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < length; index++) { + const a = left.prerelease[index]; + const b = right.prerelease[index]; + if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1; + const aNumeric = /^\d+$/.test(a); + const bNumeric = /^\d+$/.test(b); + if (aNumeric && bNumeric) { + const comparison = compareNumericText(a, b); + if (comparison !== 0) return comparison; + } else if (aNumeric !== bNumeric) { + return aNumeric ? -1 : 1; + } else { + const comparison = compareText(a, b); + if (comparison !== 0) return comparison; + } + } + return 0; +} + +function compareNumericText(left: string, right: string): number { + return left.length - right.length || compareText(left, right); +} + +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/src/validation.ts b/src/validation.ts index b2ba30e..0da1717 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,14 +1,9 @@ import { Column, GenerateRequest, Identifier, Query } from "./gen/plugin/codegen_pb.ts"; -import compatibility from "../verification/compatibility.json" with { type: "json" }; import { GenerationDiagnosticError, quoteDiagnosticValue, type Diagnostic } from "./diagnostics.ts"; +import { MINIMUM_SQLC_VERSION, TESTED_SQLC_VERSION } from "./compatibility.ts"; +import { compareSemVer, parseSemVer, type SemVer } from "./semver.ts"; -export const SQLC_COMPATIBILITY_POLICY = Object.freeze({ - supportedFloor: compatibility.sqlc.supportedFloor.replace(/^v/, ""), - testedCeiling: compatibility.sqlc.testedCeiling.replace(/^v/, ""), -}); - -export const MINIMUM_SQLC_VERSION = SQLC_COMPATIBILITY_POLICY.supportedFloor; -export const TESTED_SQLC_VERSION = SQLC_COMPATIBILITY_POLICY.testedCeiling; +export { MINIMUM_SQLC_VERSION, TESTED_SQLC_VERSION }; export const SUPPORTED_COMMANDS = [":one", ":many", ":exec", ":execrows", ":execlastid", ":execresult"] as const; export type SupportedCommand = (typeof SUPPORTED_COMMANDS)[number]; @@ -21,13 +16,8 @@ export interface ValidatedGeneration { warnings: Diagnostic[]; } -interface SemVer { - core: [string, string, string]; - prerelease: string[]; -} - -const minimumVersion = parseSemVer(SQLC_COMPATIBILITY_POLICY.supportedFloor)!; -const testedVersion = parseSemVer(SQLC_COMPATIBILITY_POLICY.testedCeiling)!; +const minimumVersion = parseSemVer(MINIMUM_SQLC_VERSION)!; +const testedVersion = parseSemVer(TESTED_SQLC_VERSION)!; export function validateGenerateRequest(request: GenerateRequest): ValidatedGeneration { const diagnostics: Diagnostic[] = []; @@ -147,52 +137,6 @@ function decodeOptions(bytes: Uint8Array, diagnostics: Diagnostic[]): { interfac return { interface: "workers" }; } -export function parseSemVer(value: string): SemVer | undefined { - const match = - /^(?:v)?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec( - value, - ); - if (!match) return undefined; - const prerelease = match[4]?.split(".") ?? []; - if (prerelease.some((identifier) => /^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0"))) - return undefined; - return { core: [match[1], match[2], match[3]], prerelease }; -} - -export function compareSemVer(left: SemVer, right: SemVer): number { - for (let index = 0; index < 3; index++) { - const comparison = compareNumericText(left.core[index], right.core[index]); - if (comparison !== 0) return comparison; - } - - if (left.prerelease.length === 0 || right.prerelease.length === 0) { - return left.prerelease.length === right.prerelease.length ? 0 : left.prerelease.length === 0 ? 1 : -1; - } - - const length = Math.max(left.prerelease.length, right.prerelease.length); - for (let index = 0; index < length; index++) { - const a = left.prerelease[index]; - const b = right.prerelease[index]; - if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1; - const aNumeric = /^\d+$/.test(a); - const bNumeric = /^\d+$/.test(b); - if (aNumeric && bNumeric) { - const comparison = compareNumericText(a, b); - if (comparison !== 0) return comparison; - } else if (aNumeric !== bNumeric) { - return aNumeric ? -1 : 1; - } else { - const comparison = compareText(a, b); - if (comparison !== 0) return comparison; - } - } - return 0; -} - -function compareNumericText(left: string, right: string): number { - return left.length - right.length || compareText(left, right); -} - function validateQuery(query: Query, queryIndex: number, diagnostics: Diagnostic[]): void { const context = (extra: Partial = {}): Partial => ({ filename: query.filename || undefined, diff --git a/test/compatibility-scripts.test.ts b/test/compatibility-scripts.test.ts deleted file mode 100644 index 559392d..0000000 --- a/test/compatibility-scripts.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { resolve } from "node:path"; -import test from "node:test"; - -const compatibility = () => import("../scripts/compatibility-config.ts"); -const sqlcMatrix = () => import("../scripts/verify-sqlc-compatibility.ts"); - -// Writes a mutated copy of the checked-in config into a throwaway root and returns it. -async function rootWithConfig(mutate: (config: any) => void): Promise { - const root = await mkdtemp(resolve(tmpdir(), "compatibility-test-")); - await mkdir(resolve(root, "verification"), { recursive: true }); - const config = JSON.parse(await readFile(resolve(process.cwd(), "verification/compatibility.json"), "utf8")); - mutate(config); - await writeFile(resolve(root, "verification/compatibility.json"), JSON.stringify(config)); - return root; -} - -async function rejectsWith(mutate: (config: any) => void, pattern: RegExp): Promise { - const { loadCompatibilityConfig } = await compatibility(); - const root = await rootWithConfig(mutate); - try { - await assert.rejects(loadCompatibilityConfig({ root }), pattern); - } finally { - await rm(root, { recursive: true, force: true }); - } -} - -test("verification/compatibility-config accepts the checked-in configuration", async () => { - const { loadCompatibilityConfig } = await compatibility(); - const config = await loadCompatibilityConfig(); - assert.equal(config.sqlc.supportedFloor, "v1.18.0"); - assert.equal(config.sqlc.testedCeiling, "v1.31.1"); - assert.deepEqual( - config.sqlc.samples.map(({ version }) => version), - ["v1.18.0", "v1.20.0", "v1.24.0", "v1.31.1"], - ); - assert.match(config.sqlc.knownExceptions[0], /v1\.18\.0 cannot parse.*sqlc\.arg.*sqlc\.embed/); - assert.match( - config.sqlc.knownExceptions[1], - /v1\.20\.0 and v1\.24\.0 parse.*Ubuntu x64 CI cells run both current fixture corpora/, - ); -}); - -test("verification/compatibility-mutations rejects unordered samples", async () => { - await rejectsWith((config) => { - config.sqlc.samples[1].version = "v1.17.0"; - }, /samples\[1\]\.version.*strictly ordered/); -}); - -test("verification/compatibility-mutations rejects duplicate sample versions", async () => { - await rejectsWith((config) => { - config.sqlc.samples[1].version = config.sqlc.samples[0].version; - }, /sqlc\.samples.*versions must be unique/); -}); - -test("verification/compatibility-mutations rejects a floor that disagrees with the first sample", async () => { - await rejectsWith((config) => { - config.sqlc.supportedFloor = "v1.19.0"; - }, /sqlc\.supportedFloor.*must equal first sample v1\.18\.0/); -}); - -test("verification/compatibility-mutations rejects a ceiling that disagrees with the last sample", async () => { - await rejectsWith((config) => { - config.sqlc.testedCeiling = "v1.32.0"; - }, /sqlc\.testedCeiling.*must equal last sample v1\.31\.1/); -}); - -test("verification/compatibility-mutations rejects samples that do not start at the floor", async () => { - await rejectsWith((config) => { - config.sqlc.samples[0].role = "intervening"; - }, /sqlc\.samples.*exactly one floor role/); -}); - -test("verification/sqlc-matrix reports the honest corpus selected for every sample", async () => { - const { fixturesForSqlcVersion } = await sqlcMatrix(); - assert.deepEqual( - fixturesForSqlcVersion("v1.18.0").map(({ directory }) => directory), - ["test/sqlc-v1-18"], - ); - for (const version of ["v1.20.0", "v1.24.0", "v1.31.1"]) - assert.deepEqual( - fixturesForSqlcVersion(version).map(({ directory }) => directory), - ["test/miniflare", "examples/d1-worker"], - ); -}); diff --git a/test/generator/compile.ts b/test/generator/compile.ts index 1fce77c..c63899a 100644 --- a/test/generator/compile.ts +++ b/test/generator/compile.ts @@ -5,7 +5,6 @@ import { spawnSync } from "node:child_process"; import type { GenerateResponse } from "../../src/gen/plugin/codegen_pb.ts"; export interface CompileGeneratedOptions { - compiler?: "typescript-5-2" | "typescript"; additionalFiles?: Readonly>; } @@ -50,7 +49,7 @@ export function compileGeneratedResponse(response: GenerateResponse, options: Co }), ); - const compiler = resolve(process.cwd(), `node_modules/${options.compiler ?? "typescript-5-2"}/lib/tsc.js`); + const compiler = resolve(process.cwd(), "node_modules/typescript/lib/tsc.js"); const result = spawnSync(process.execPath, [compiler, "-p", resolve(directory, "tsconfig.json")], { encoding: "utf8", }); diff --git a/test/generator/scenarios.ts b/test/generator/scenarios.ts index 5f688a4..7c6591a 100644 --- a/test/generator/scenarios.ts +++ b/test/generator/scenarios.ts @@ -244,12 +244,9 @@ const currentCommands: GeneratorScenario = { expected: "the private DB.withSession capability", received: "an invalid capability", }); - for (const compiler of ["typescript-5-2", "typescript"] as const) { - compileGeneratedResponse(response, { - compiler, - additionalFiles: { "consumer.ts": publicApiConsumer }, - }); - } + compileGeneratedResponse(response, { + additionalFiles: { "consumer.ts": publicApiConsumer }, + }); for (const unsafePath of ["../consumer.ts", "/consumer.ts", "tsconfig.json", "queries_sql.ts"]) { assert.throws(() => compileGeneratedResponse(response, { @@ -467,9 +464,7 @@ const commandSemantics: GeneratorScenario = { assert.doesNotMatch(bare, /generatedInternals|d1_values|d1_Context/); assert.match(bare, /^import type \{ QueryDescriptor \} from "\.\/runtime";$/m); - for (const compiler of ["typescript-5-2", "typescript"] as const) { - compileGeneratedResponse(response, { compiler, additionalFiles: { "consumer.ts": commandsConsumer } }); - } + compileGeneratedResponse(response, { additionalFiles: { "consumer.ts": commandsConsumer } }); }, }; @@ -898,9 +893,7 @@ const argumentModel: GeneratorScenario = { assert.equal(JSON.parse(literal), query.text, query.name); } - for (const compiler of ["typescript-5-2", "typescript"] as const) { - compileGeneratedResponse(response, { compiler, additionalFiles: { "consumer.ts": argumentsConsumer } }); - } + compileGeneratedResponse(response, { additionalFiles: { "consumer.ts": argumentsConsumer } }); }, }; @@ -1350,23 +1343,9 @@ const many: Promise = db.execute(listItems()); const descriptor: QueryDescriptor = createItem(); void [one, many, descriptor]; `; - for (const compiler of ["typescript-5-2", "typescript"] as const) { - compileGeneratedResponse(outcome.response, { - compiler, - additionalFiles: { "admin/consumer.ts": nestedConsumer }, - }); - } - }, -}; - -const typescriptFloor: GeneratorScenario = { - id: "generator/typescript-floor", - createInput: () => queryInput(createSafeEmissionRequest()), - assert(outcome) { - assert.equal(outcome.exitCode, 0, outcome.diagnostics); - assert.ok(outcome.response); - compileGeneratedResponse(outcome.response, { compiler: "typescript-5-2" }); - compileGeneratedResponse(outcome.response, { compiler: "typescript" }); + compileGeneratedResponse(outcome.response, { + additionalFiles: { "admin/consumer.ts": nestedConsumer }, + }); }, }; @@ -1495,12 +1474,9 @@ const value: D1Value = null; const error: Error = new QueryResultError("bad row", { operation: "execute", queryName: "GetUser", rowIndex: 0 }); void [session, value, error]; `; - for (const compiler of ["typescript-5-2", "typescript"] as const) { - compileGeneratedResponse(outcome.response, { - compiler, - additionalFiles: { "consumer.ts": consumer }, - }); - } + compileGeneratedResponse(outcome.response, { + additionalFiles: { "consumer.ts": consumer }, + }); }, }; @@ -1718,9 +1694,7 @@ const checkedValues: GeneratorScenario = { assert.doesNotMatch(source, /row\[[^\]]+\] as /); } - for (const compiler of ["typescript-5-2", "typescript"] as const) { - compileGeneratedResponse(response, { compiler, additionalFiles: { "consumer.ts": checkedValuesConsumer } }); - } + compileGeneratedResponse(response, { additionalFiles: { "consumer.ts": checkedValuesConsumer } }); }, }; @@ -3149,9 +3123,7 @@ const embedModel: GeneratorScenario = { /^ {8}"users": \{\n {12}"id": d1_values\.rowInteger\(row, "d1_embed_0_0", "users\.id", ctx\),\n {12}"name": d1_values\.rowText\(row, "d1_embed_0_1", "users\.name", ctx\)\n {8}\},$/m, ); - for (const compiler of ["typescript-5-2", "typescript"] as const) { - compileGeneratedResponse(response, { compiler, additionalFiles: { "consumer.ts": embedsConsumer } }); - } + compileGeneratedResponse(response, { additionalFiles: { "consumer.ts": embedsConsumer } }); }, }; @@ -3573,7 +3545,6 @@ export const generatorScenarios = [ embedBoundary, diagnosticAggregation, safeEmission, - typescriptFloor, emissionDiagnostics, emissionDeterminism, noQueryRuntime, diff --git a/test/generator/validation.test.ts b/test/generator/validation.test.ts index d94346d..c4f15de 100644 --- a/test/generator/validation.test.ts +++ b/test/generator/validation.test.ts @@ -59,13 +59,13 @@ test("compatibility validation implements the sqlc semantic-version floor and wa assert.deepEqual(reasons(request({ settings: new Settings({ engine: "postgresql" }) })), [ "COMPATIBILITY/UNSUPPORTED_ENGINE", ]); - for (const sqlcVersion of ["v1.18.0", "1.18.0", "v1.31.1", "v1.31.1+build.7"]) { + for (const sqlcVersion of ["v1.25.0", "1.25.0", "v1.28.0", "v1.31.1", "v1.31.1+build.7"]) { assert.equal(validateGenerateRequest(request({ sqlcVersion })).warnings.length, 0); } - for (const sqlcVersion of ["1.18", "1.018.0", "1.18.0-", "1.18.0-01", "1.18.0+bad..build"]) { + for (const sqlcVersion of ["1.25", "1.025.0", "1.25.0-", "1.25.0-01", "1.25.0+bad..build"]) { assert.deepEqual(reasons(request({ sqlcVersion })), ["COMPATIBILITY/MALFORMED_SQLC_VERSION"]); } - for (const sqlcVersion of ["v1.17.9", "v1.18.0-rc.1"]) { + for (const sqlcVersion of ["v1.18.0", "v1.24.9", "v1.25.0-rc.1"]) { assert.deepEqual(reasons(request({ sqlcVersion })), ["COMPATIBILITY/UNSUPPORTED_SQLC_VERSION"]); } for (const sqlcVersion of ["v1.31.2", "v1.32.0-rc.1", "999999999999999999999.0.0"]) { diff --git a/test/release-scripts.test.ts b/test/release-scripts.test.ts index 351eea2..9e2e8bb 100644 --- a/test/release-scripts.test.ts +++ b/test/release-scripts.test.ts @@ -9,7 +9,7 @@ import { stableJson, type ReleaseIntent, } from "../scripts/release.ts"; -import { loadCompatibilityConfig } from "../scripts/compatibility-config.ts"; +import { MINIMUM_SQLC_VERSION, TESTED_SQLC_VERSION } from "../src/compatibility.ts"; const sha = "0123456789abcdef0123456789abcdef01234567"; const intent: ReleaseIntent = { @@ -76,10 +76,9 @@ test("release/intent refuses anything that is not a tag on default-branch lineag await assert.rejects(resolveReleaseIntent({ ...base, sourceCommit: "abc" }), /40-character/); }); -test("release/manifest describes the exact bytes and is byte-stable", async () => { - const config = await loadCompatibilityConfig(); +test("release/manifest describes the exact bytes and is byte-stable", () => { const bytes = new TextEncoder().encode("plugin bytes"); - const manifest = createReleaseManifest({ intent, bytes, config }); + const manifest = createReleaseManifest({ intent, bytes }); assert.equal(manifest.artifact.filename, "sqlc-gen-d1-typescript_0.2.0.wasm"); assert.equal(manifest.artifact.size, bytes.length); @@ -87,22 +86,20 @@ test("release/manifest describes the exact bytes and is byte-stable", async () = assert.equal(manifest.artifact.url, `https://sqlc.mkuznets.com/plugins/${manifest.artifact.filename}`); assert.equal(manifest.source_commit, sha); assert.equal(manifest.version, "0.2.0"); - assert.deepEqual( - manifest.tested_versions.sqlc, - config.sqlc.samples.map(({ version }) => version), - ); - assert.deepEqual(manifest.tested_versions.typescript, [config.typescript.floor, config.typescript.current]); + assert.deepEqual(manifest.tested_versions.sqlc, { + floor: MINIMUM_SQLC_VERSION, + ceiling: TESTED_SQLC_VERSION, + }); // The manifest is published, so its encoding must not depend on key insertion order. - assert.equal(stableJson(createReleaseManifest({ intent, bytes, config })), stableJson(manifest)); + assert.equal(stableJson(createReleaseManifest({ intent, bytes })), stableJson(manifest)); assert.equal(stableJson(manifest), stableJson(JSON.parse(stableJson(manifest)))); assert.ok(stableJson(manifest).endsWith("}\n")); }); -test("release/manifest refuses a tag that does not name its version", async () => { - const config = await loadCompatibilityConfig(); +test("release/manifest refuses a tag that does not name its version", () => { assert.throws( - () => createReleaseManifest({ intent: { ...intent, tag: "v0.3.0" }, bytes: new Uint8Array(1), config }), + () => createReleaseManifest({ intent: { ...intent, tag: "v0.3.0" }, bytes: new Uint8Array(1) }), /does not name version/, ); }); diff --git a/test/sqlc-v1-18/queries.sql b/test/sqlc-v1-18/queries.sql deleted file mode 100644 index f0c2f0c..0000000 --- a/test/sqlc-v1-18/queries.sql +++ /dev/null @@ -1,17 +0,0 @@ --- name: GetRecord :one -SELECT id, name FROM records WHERE id = ?; - --- name: ListRecords :many -SELECT id, name FROM records ORDER BY id; - --- name: InsertRecord :exec -INSERT INTO records (name) VALUES (?); - --- name: RenameRecord :execrows -UPDATE records SET name = ? WHERE id = ?; - --- name: AddRecord :execlastid -INSERT INTO records (name) VALUES (?); - --- name: DeleteRecord :execresult -DELETE FROM records WHERE id = ?; diff --git a/test/sqlc-v1-18/schema.sql b/test/sqlc-v1-18/schema.sql deleted file mode 100644 index a6f62b0..0000000 --- a/test/sqlc-v1-18/schema.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE records ( - id integer PRIMARY KEY, - name text NOT NULL -); diff --git a/test/sqlc-v1-18/sqlc.yaml b/test/sqlc-v1-18/sqlc.yaml deleted file mode 100644 index e79d072..0000000 --- a/test/sqlc-v1-18/sqlc.yaml +++ /dev/null @@ -1,14 +0,0 @@ -version: "2" -plugins: - - name: ts - wasm: - url: file:///candidate-required/plugin.wasm -sql: - - schema: "schema.sql" - queries: "queries.sql" - engine: "sqlite" - codegen: - - plugin: ts - out: src - options: - interface: workers diff --git a/test/sqlc-v1-18/tsconfig.json b/test/sqlc-v1-18/tsconfig.json deleted file mode 100644 index 00e9d7e..0000000 --- a/test/sqlc-v1-18/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "es2020", - "lib": ["es2020"], - "module": "es2022", - "moduleResolution": "Bundler", - "noEmit": true, - "strict": true, - "skipLibCheck": true, - "types": [] - }, - "include": ["src/**/*.ts"] -} diff --git a/test/types/candidate.test.ts b/test/types/candidate.test.ts index 2f9c96c..f374a5b 100644 --- a/test/types/candidate.test.ts +++ b/test/types/candidate.test.ts @@ -5,20 +5,16 @@ import { createCandidateHarness } from "../generator/candidate.ts"; import { compileGeneratedResponse } from "../generator/compile.ts"; import { createPublicTypesRequest } from "../generator/scenarios.ts"; import { publicTypesConsumer } from "./consumer.ts"; -import { typeCatalog } from "./catalog.ts"; const candidate = process.env.CANDIDATE_WASM; if (!candidate) throw new Error("CANDIDATE_WASM is required"); const harness = createCandidateHarness({ bytes: readFileSync(candidate) }); -for (const catalog of typeCatalog) { - test(catalog.id, async () => { - const outcome = await (await harness).run(createPublicTypesRequest()); - assert.equal(outcome.exitCode, 0, outcome.diagnostics); - assert.ok(outcome.response); - compileGeneratedResponse(outcome.response, { - compiler: catalog.id.endsWith("5-2") ? "typescript-5-2" : "typescript", - additionalFiles: { "consumer.ts": publicTypesConsumer }, - }); +test("types/public-api", async () => { + const outcome = await (await harness).run(createPublicTypesRequest()); + assert.equal(outcome.exitCode, 0, outcome.diagnostics); + assert.ok(outcome.response); + compileGeneratedResponse(outcome.response, { + additionalFiles: { "consumer.ts": publicTypesConsumer }, }); -} +}); diff --git a/test/types/catalog.ts b/test/types/catalog.ts deleted file mode 100644 index 3f05fed..0000000 --- a/test/types/catalog.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const typeCatalog = [ - { - id: "types/typescript-5-2", - layer: "types", - file: "test/types/candidate.test.ts", - title: "public API compiles with TypeScript 5.2", - availability: "local", - }, - { - id: "types/typescript-current", - layer: "types", - file: "test/types/candidate.test.ts", - title: "public API compiles with current TypeScript", - availability: "local", - }, -] as const; diff --git a/test/verification-contracts.test.ts b/test/verification-contracts.test.ts deleted file mode 100644 index 87a3973..0000000 --- a/test/verification-contracts.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { readFileSync, readdirSync } from "node:fs"; -import { resolve } from "node:path"; -import test from "node:test"; - -// Three properties that CI itself cannot tell us about, because a workflow that -// violates them still runs: an unpinned action, a credential visible outside the -// job that needs it, and a script that cannot load before the toolchain exists. - -const read = (path: string): string => readFileSync(resolve(process.cwd(), path), "utf8"); -const workflows = (): string[] => - readdirSync(resolve(process.cwd(), ".github/workflows")).map((name) => `.github/workflows/${name}`); -const actions = (): string[] => [".github/actions/setup/action.yml"]; - -test("every action every workflow uses is pinned to a full commit SHA", () => { - for (const file of [...workflows(), ...actions()]) - for (const action of read(file).matchAll(/uses:\s*([^\s#]+)/g)) - if (!action[1].startsWith("./")) assert.match(action[1], /@[0-9a-f]{40}$/, `${file}: ${action[1]}`); -}); - -test("only the publish job may write contents or see publication credentials", () => { - const release = read(".github/workflows/release.yml"); - const publish = release.slice(release.indexOf(" publish:")); - const others = release.replace(publish, ""); - - assert.match(publish, /environment: release-publication/); - assert.match(publish, /permissions:\s*\n\s+contents: write/); - assert.doesNotMatch(others, /contents: write|secrets\.R2_|environment: release-publication/); - assert.equal((release.match(/contents: write/g) ?? []).length, 1); - - // Credentials reach the step that needs them, never a job-level `env:` block that - // every step of the job would inherit. - const jobEnv = /\n env:\n((?: [^\n]*\n)*)/.exec(publish)?.[1] ?? ""; - assert.doesNotMatch(jobEnv, /secrets\.|R2_ACCESS_KEY_ID|R2_SECRET_ACCESS_KEY/); - - assert.doesNotMatch(read(".github/workflows/ci.yml"), /R2_|release-publication|CLOUDFLARE/i); -}); - -// pins.mjs chooses the Node version, so it runs on whatever Node the runner shipped -// with and before `npm ci`. TypeScript syntax or a package import turns that into a -// startup crash rather than a test failure. -test("the script that pins the toolchain runs before the toolchain exists", () => { - const path = "scripts/workflows/pins.mjs"; - for (const found of read(path).matchAll(/^import\s+(?:[^"']*?\sfrom\s+)?["']([^"']+)["']/gm)) - assert.ok(found[1].startsWith("node:"), `pins.mjs imports ${found[1]}, which is absent before npm ci`); - - // TypeScript syntax is not valid JavaScript, so parsing it as JavaScript is the - // honest check — a Node that cannot strip types has to be able to read this file. - const parsed = spawnSync(process.execPath, ["--check", resolve(process.cwd(), path)], { encoding: "utf8" }); - assert.equal(parsed.status, 0, `pins.mjs must parse as plain JavaScript:\n${parsed.stderr}`); -}); diff --git a/verification/compatibility.json b/verification/compatibility.json deleted file mode 100644 index 73f1f5d..0000000 --- a/verification/compatibility.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "sqlc": { - "supportedFloor": "v1.18.0", - "testedCeiling": "v1.31.1", - "samples": [ - { "version": "v1.18.0", "role": "floor", "rationale": "Oldest supported Plugin protocol baseline." }, - { "version": "v1.20.0", "role": "intervening", "rationale": "Includes the material sqlc.slice generation fix." }, - { - "version": "v1.24.0", - "role": "intervening", - "rationale": "Refactors the Plugin interface around GenerateRequest." - }, - { "version": "v1.31.1", "role": "ceiling", "rationale": "Newest release verified by the compatibility suite." } - ], - "knownExceptions": [ - "sqlc v1.18.0 cannot parse the current sqlc.arg, sqlc.narg, sqlc.slice, or sqlc.embed fixture syntax; its matrix cell uses test/sqlc-v1-18 to cover all six ordinary commands and positional binds.", - "sqlc v1.20.0 and v1.24.0 parse the current fixture syntax, but their legacy Plugin WASM runtimes cannot execute the publication candidate from the official release binaries on macOS arm64; their Ubuntu x64 CI cells run both current fixture corpora." - ] - }, - "typescript": { "floor": "5.2.2", "current": "5.9.3" }, - "tools": { - "node": "24.12.0", - "npm": "11.6.2", - "bun": "1.3.10" - } -}