Skip to content

feat(staged-query)!: drop casts, thenable queries and caller-typed schema lookups - #91

Merged
Upd4ting merged 3 commits into
mainfrom
feat/drop-published-api-suppressions
Nov 13, 2026
Merged

Upd4ting merged 3 commits into
mainfrom
feat/drop-published-api-suppressions

Conversation

@Upd4ting

@Upd4ting Upd4ting commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

Why

Antony wants the lint in this repo to hold with no escape hatches (ANT-1103). #88 and #90 removed every suppression that could go without a published change. The rest sat on published signatures, and Antony approved one breaking PR for them. With it, every ceiling in guardrails.suppressions.json is 0: no oxlint-disable, @ts-expect-error or SAFETY: comment is left anywhere in the repo. It must ship as 0.4.0, because database modules cap their range below the next minor.

Breaking changes

Each item is in UPGRADING.md under ## v0.4.0, with a before and after snippet.

  1. A query is no longer a promise. then is gone from Query.prototype, and QueryExecution no longer extends PromiseLike. await query gives back the query object and runs nothing. Callers use .run(), cursor() or for await. This removes the unicorn/no-thenable directive. A type-aware codemod moved every await <query> in src/tests, docs/, README.md, skills/ and .agents/skills/verify-interface to .run(), and lint (typescript/await-thenable) reports none left.
  2. cast<U>() returns a new object with the same stages on Datum, Stream and ValueProxy. It used to return this through as unknown as. A new constructor overload, new X(undefined, previous), copies previous's stages and records none. This removes 3 SAFETY: comments and 1 anti-slop/no-chained-type-assertions directive. On a Table, Selection or SingleSelection, cast() now returns a plain Stream or Datum, the class its type already declared.
  3. Schema.get(id) takes no type arguments and returns Schema<any, unknown> | undefined, which is what the registry holds. This removes 1 SAFETY: comment. Docs that called Schema.get<{ users: User }>(…) now use the schema object the constructor returned.
  4. Protected callfunc takes 1 or 2 typed constructors (two overloads) and calls func without a tuple cast. This removes 1 SAFETY: comment. The recorded stages are byte-identical, with argument ids still allocated a, then b. Stream#group now passes the ValueProxy type argument its mapper declares, which only changes the type.
  5. The build-only Query<T> now uses T. This removes the typescript/no-unused-vars and typescript/no-unnecessary-type-parameters directives, and fixes a real type hole. Loaded from staged-query alone, Query<string> was assignable to Query<number>, and ExtractType<Datum<number>> was unknown. The class declares declare protected readonly [YIELDS]?: QueryYield<T>.

The Query<T> member, for the adversarial check

It is a declared-only member: declare emits nothing, and no instance ever has it. In the build-only entry, a query holds stages and never a value of type T. So any member using T is declared, not stored. Two reviewers (Opus and Sonnet) each judged it, and both found it a real fix, not a brand. They ran the type probe added to scripts/test-staged-query.cjs against main's build, and all four assertions fail there. Neither found a fix without a member:

  • Matching Datum<infer A> / Stream<infer A> in ExtractType still leaves T unused, and loses bare Query<T>.
  • Merging QueryExecution<T> into the build-only entry would give it a run() it does not have.
  • T cannot be dropped, because declaration merging with src/query.ts needs the same type parameters.

The key is YIELDS, a declare const YIELDS: unique symbol no other module can name, so a subclass property named yields does not collide with it.

No SAFETY: comment is added.

Proof

New tests at origin/main (55853ef), with the head's test files copied in:

  1) Awaiting a query gives back the query and runs nothing
  2) Casts a stream to a new stream with the same stages
  3) Casts a datum to a new datum with the same stages
  4) Casts a proxy to a new proxy with the same stages
  7 passing (13ms)
  4 failing
  1) Query execution
      AssertionError: expected true to equal false
  2) Recorded stages
      AssertionError: expected Table{ stages: [ { …(3) }, …(2) ] } to not equal Table{ stages: [ { …(3) }, …(2) ] }
src/tests/basic_operations.test.ts(51,9): error TS2322: Type 'true' is not assignable to type 'false'.   (Schema get claims no definition type)
AssertionError [ERR_ASSERTION]: staged-query types read T back from queries   (scripts/test-staged-query.cjs)

At head:

    ✔ Awaiting a query gives back the query and runs nothing
    ✔ Casts a stream to a new stream with the same stages
    ✔ Casts a datum to a new datum with the same stages
    ✔ Casts a proxy to a new proxy with the same stages
    ✔ Schema get claims no definition type
  28 passing (93ms)
staged-query purity, AQL stages, and root compatibility: PASS
  • pnpm run checks: Checks: 11 passed, 0 failed.
  • The docs type-check (tsc -p docs/tsconfig.json) is clean.
flowchart LR
  subgraph Before
    A1[await query] -->|then| R1[RunQuery]
    C1["cast()"] -->|as unknown as| S1[same object]
  end
  subgraph After
    A2[await query] --> Q2[the query object, nothing runs]
    B2["query.run()"] --> R2[RunQuery]
    C2["cast()"] -->|"new X(undefined, this)"| S2[new object, same stages]
  end
Loading

verify-interface

A thread that did not write the change ran .agents/skills/verify-interface at 452d96c. All 30 journeys the harness registers pass. The query-execution evidence:

{"lazy":0,"awaitedIsQuery":true,"awaitRuns":0,"viaRun":[{"title":"A"}],"runs":3,"sameStages":true,"rejected":"Error: provider refused","onRejected":"caught:provider refused","methods":["function","undefined","function"],"iterator":"function"}

minor-bump and every-function did not run. features/smoke.md deliberately leaves both unregistered in this repo. The only later commit, b95e426, keeps StagedObject.length at 2 and moves the argument helper to module scope. pnpm run checks and pnpm test:staged-query cover it, and both pass.

API diff

antelopejs-api-diff origin/main exits 1 here, as expected for a breaking PR. Its full output is 137 KB, more than twice GitHub's 65,536-character body limit, so it is not pasted. To reproduce: pnpm exec antelopejs-api-diff origin/main at the head of this PR. Its closing stamp then reads base=55853ef… head=b95e426… lines=3837 digest=c2620097c6c135f4. What it reports:

  • Declarations (repeated in each entry and typesVersions file):
    • Query<T> gains the [YIELDS] member and the new QueryYield<T> interface.
    • QueryExecution<T> drops PromiseLike<T> and types cursor() from QueryYield<T>["item"].
    • Schema.get loses its type parameters.
    • StagedObject gains the (undefined, previous) constructor overload.
    • callfunc becomes two overloads.
  • Runtime:
    • then is removed from Query.prototype, in the root, query and main entries.
    • StagedObject.prototype.callfunc.length goes from 1 to 0.
    • Nothing else: StagedObject.length stays 2, and no static is added.
  • Suppressions: Lint suppressions, all rules 4 → 0 and SAFETY: comments 5 → 0. Every other row was already 0.

Downstream

Every module or app that awaits a query must call .run() once it moves to 0.4.0. From reading their source, these also stop compiling:

  • dms: getTenantSchema(): Schema returning Schema.get(…).
  • dms-database: Object.hasOwn(schema.definition, …), and its console's Schema.get<unknown, SchemaDefLike>.

UPGRADING.md points root callers to Schema.list().get(id), which stays typed with SchemaDefinition.

…hema lookups

Remove every suppression guardrails.suppressions.json still allowed on
published declarations, by fixing the code each one hid:

- cast() on Datum, Stream and ValueProxy returns a new object of that
  class with the same stages, through a StagedObject constructor form
  `new X(undefined, previous)` that copies the stages without recording
  one. No type assertion is left.
- Query<T> declares what it yields, `QueryYield<T>`, so the build-only
  entry tells Query<string> from Query<number> and ExtractType reads T
  back from a Datum or Stream; QueryExecution types its cursor items
  from it.
- Queries are no longer thenable: `then` is gone and QueryExecution no
  longer extends PromiseLike. The conformance suite, docs, README, the
  shipped skill and the verify scenarios run queries with run().
- Schema.get(id) takes no type arguments and returns what the registry
  holds, Schema<any, unknown> | undefined.
- The protected callfunc takes one or two typed StagedConstructors and
  calls func without a tuple cast; group passes the argument class its
  mapper declares.

The suppression budget drops to 0 for every rule and SAFETY comment;
the 2 @ts-expect-error in atomic_mutation.test.ts stay for #90.

BREAKING CHANGE: `await query` no longer runs a query; call run(), or
read it with cursor() or for await. cast() returns a new object instead
of its receiver, and on a Table, Selection or SingleSelection returns a
plain Stream or Datum. Schema.get() takes no type arguments and types
the schema's definition unknown. The build-only Query<A> is no longer
assignable to Query<B>. The protected StagedObject#callfunc takes one
or two typed constructors. UPGRADING.md has before and after snippets.
@Upd4ting
Upd4ting merged commit be29412 into main Nov 13, 2026
8 checks passed
@Upd4ting
Upd4ting deleted the feat/drop-published-api-suppressions branch November 13, 2026 11:08
Upd4ting added a commit that referenced this pull request Nov 13, 2026
## Why

#91 is a breaking change, so it has to ship as 0.4.0: database modules
cap their range below the next minor. This repo's release workflow takes
no `release-version` input. Dispatching it with one fails with
`Unexpected inputs provided`, and the automatic increment would publish
the breaking change as a patch. This PR adds the optional input and
passes it to
`AntelopeJS/.github/.github/workflows/release-npm-public.yml@v1`, the
same way tooling-configs' `release.yml` does.

## What

`.github/workflows/release.yml` gets the optional string input
`release-version`, which it passes to the reusable workflow. When it is
empty, the automatic increment applies as before.

## Proof

Before, dispatching with `-f release-version=0.4.0`:

```
Unexpected inputs provided
```

After, the file is byte-identical to tooling-configs' `release.yml`:

```
$ gh api repos/AntelopeJS/tooling-configs/contents/.github/workflows/release.yml -q .content | base64 -d | diff - .github/workflows/release.yml && echo identical-to-tooling-configs
identical-to-tooling-configs
```

The reusable workflow at `v1` declares the input and checks it:

```
      release-version:
        description: Exact stable version; empty uses the automatic increment
        default: ""
        type: string
...
            test "$CHANNEL" = latest && [[ "$RELEASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || {
              echo "release-version must be a stable version on the latest channel." >&2
```

`pnpm run checks`: `Checks: 11 passed, 0 failed.` I did not dispatch a
test run, because it would publish a release.
Upd4ting added a commit that referenced this pull request Nov 13, 2026
## Why

A user upgrading `@antelopejs/interface-database` to 0.4.0 should learn
from its release notes which changes break their code and how to adapt.
Today the `## v0.4.0` section of `CHANGELOG.md` and the v0.4.0 GitHub
release name #91 only as "Drop casts, thenable queries and caller-typed
schema lookups". Its upgrade notes live in `UPGRADING.md`. The release
commit (7268503) does not copy them, so this PR does, as AGENTS.md asks.

## What

This adds a `### Upgrade notes` block under `## v0.4.0` in
`CHANGELOG.md`, right after the compare link, where the v0.3.0 block
sits. It holds the `UPGRADING.md` v0.4.0 bullets verbatim, snippets
included. It lists no implementer action: database modules implement
nothing new, and the conformance suite in `dist/tests` already runs
every query with `run()`.

## Pending step for Antony

I have not edited the v0.4.0 GitHub release body. Paste the text under
**Release notes to paste** into
https://github.com/AntelopeJS/interface-database/releases/tag/v0.4.0. It
is the current release body with the same upgrade-notes block after the
compare link.

## Release notes to paste

````markdown
[compare changes](v0.3.3...v0.4.0)

### Upgrade notes

- **A query is no longer a promise.** `Query` has no `then()`, and `QueryExecution` no longer extends `PromiseLike`. `await query` gives back the query object and sends nothing to the provider. Run a query with `run()`, or read it with `cursor()` or `for await`. A query in `Promise.all([...])`, or returned from an `async` function, is no longer run either. TypeScript rejects a query passed where a `PromiseLike` is expected, and the `typescript/await-thenable` lint rule reports `await` on a query and a query passed to `Promise.all`, `Promise.race` or another promise aggregator. The conformance suite in `dist/tests` runs every query with `run()`. A database module implements nothing new.

  ```ts
  // Before
  const adults = await users.filter((u) => u.key("age").ge(18));
  // After
  const adults = await users.filter((u) => u.key("age").ge(18)).run();
  ```

- **`cast()` returns a new object.** `Datum#cast`, `Stream#cast` and `ValueProxy#cast` return a new object with the same stages, built by the new `StagedObject` constructor form `new X(undefined, previous)`, which records no stage. `cast()` on a `Table`, a `Selection` or a `SingleSelection` returns a plain `Stream` or `Datum`, the class its type already declared. Code that compared the result with its receiver, or relied on it keeping the subclass methods at runtime, such as a table's `insert` or `getAll`, keeps the receiver instead.

  ```ts
  // Before: the same object, still a Table at runtime
  table.cast<Row>() === table; // true
  // After: a new Stream with the same stages
  table.cast<Row>() === table; // false
  table.cast<Row>().build(); // equal to table.build()
  ```

- **`Schema.get()` takes no type arguments.** The registry keeps none, so `Schema.get(id)` returns `Schema<any, unknown> | undefined`: tables hold `any` documents, and the definition is typed `unknown`. A typed lookup such as `Schema.get<{ users: User }>(id)` fails to compile (TS2558). Query typed tables through the schema object the constructor returned. On the runtime entry, `Schema.list().get(id)` returns the schema with its definition typed `SchemaDefinition`.

  ```ts
  // Before
  const schema = Schema.get<{ users: User }>("myapp")!;
  // After: export the schema you define, and import it
  export const schema = new Schema<{ users: User }>("myapp", definition);
  ```

- **The build-only `Query<T>` keeps its result type.** Loaded from `@antelopejs/interface-database/staged-query` alone, `Query<T>` now uses `T`, so `ExtractType` reads the result type of a `Datum` or a `Stream` a mapper returns, and a `Query<string>` is no longer assignable to a `Query<number>`. Code that relied on that assignment fails to compile.

  ```ts
  // Before, build-only entry: compiled, typed unknown
  const q: Query<number> = schema.createInstance(); // Query<string>
  // After: TS2322
  ```

- **`StagedObject#callfunc` checks its argument classes.** The protected method takes one or two `StagedConstructor`s, and each must build the parameter type `func` declares. A subclass that passed `ValueProxy<unknown>` for a parameter typed `ValueProxy<T>` passes `ValueProxy<T>`.

  ```ts
  // Before
  this.callfunc(mapper, Stream<T>, ValueProxy<unknown>);
  // After
  this.callfunc(
    mapper,
    Stream<T>,
    ValueProxy<K extends keyof T ? T[K] : unknown>,
  );
  ```

### 🚀 Enhancements

- **staged-query:** ⚠️  Drop casts, thenable queries and caller-typed schema lookups ([#91](#91))

### 💅 Refactors

- **tests:** Drop the no-unused-expressions suppression ([#88](#88))

### 📖 Documentation

- **readme:** List the mysql and postgres backends among the implementations ([#89](#89))

### ✅ Tests

- **atomic:** Move off-contract refusal checks into an untyped spec ([#90](#90))

### 🤖 CI

- **release:** Accept an exact release-version on dispatch ([#92](#92))

#### ⚠️ Breaking Changes

- **staged-query:** ⚠️  Drop casts, thenable queries and caller-typed schema lookups ([#91](#91))

### ❤️ Contributors

- Antony Rizzitelli ([@Upd4ting](http://github.com/Upd4ting))
````

## Proof

The inserted block matches `UPGRADING.md`'s v0.4.0 block line for line:

```
$ diff <(grep -v '^$' notes-from-UPGRADING.md) <(grep -v '^$' block-in-CHANGELOG.md) && echo verbatim
verbatim
```

CHANGELOG diff:

````diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb189cc..d22fdb7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,57 @@
 
 [compare changes](v0.3.3...v0.4.0)
 
+### Upgrade notes
+
+- **A query is no longer a promise.** `Query` has no `then()`, and `QueryExecution` no longer extends `PromiseLike`. `await query` gives back the query object and sends nothing to the provider. Run a query with `run()`, or read it with `cursor()` or `for await`. A query in `Promise.all([...])`, or returned from an `async` function, is no longer run either. TypeScript rejects a query passed where a `PromiseLike` is expected, and the `typescript/await-thenable` lint rule reports `await` on a query and a query passed to `Promise.all`, `Promise.race` or another promise aggregator. The conformance suite in `dist/tests` runs every query with `run()`. A database module implements nothing new.
+
+  ```ts
+  // Before
+  const adults = await users.filter((u) => u.key("age").ge(18));
+  // After
+  const adults = await users.filter((u) => u.key("age").ge(18)).run();
+  ```
+
+- **`cast()` returns a new object.** `Datum#cast`, `Stream#cast` and `ValueProxy#cast` return a new object with the same stages, built by the new `StagedObject` constructor form `new X(undefined, previous)`, which records no stage. `cast()` on a `Table`, a `Selection` or a `SingleSelection` returns a plain `Stream` or `Datum`, the class its type already declared. Code that compared the result with its receiver, or relied on it keeping the subclass methods at runtime, such as a table's `insert` or `getAll`, keeps the receiver instead.
+
+  ```ts
+  // Before: the same object, still a Table at runtime
+  table.cast<Row>() === table; // true
+  // After: a new Stream with the same stages
+  table.cast<Row>() === table; // false
+  table.cast<Row>().build(); // equal to table.build()
+  ```
+
+- **`Schema.get()` takes no type arguments.** The registry keeps none, so `Schema.get(id)` returns `Schema<any, unknown> | undefined`: tables hold `any` documents, and the definition is typed `unknown`. A typed lookup such as `Schema.get<{ users: User }>(id)` fails to compile (TS2558). Query typed tables through the schema object the constructor returned. On the runtime entry, `Schema.list().get(id)` returns the schema with its definition typed `SchemaDefinition`.
+
+  ```ts
+  // Before
+  const schema = Schema.get<{ users: User }>("myapp")!;
+  // After: export the schema you define, and import it
+  export const schema = new Schema<{ users: User }>("myapp", definition);
+  ```
+
+- **The build-only `Query<T>` keeps its result type.** Loaded from `@antelopejs/interface-database/staged-query` alone, `Query<T>` now uses `T`, so `ExtractType` reads the result type of a `Datum` or a `Stream` a mapper returns, and a `Query<string>` is no longer assignable to a `Query<number>`. Code that relied on that assignment fails to compile.
+
+  ```ts
+  // Before, build-only entry: compiled, typed unknown
+  const q: Query<number> = schema.createInstance(); // Query<string>
+  // After: TS2322
+  ```
+
+- **`StagedObject#callfunc` checks its argument classes.** The protected method takes one or two `StagedConstructor`s, and each must build the parameter type `func` declares. A subclass that passed `ValueProxy<unknown>` for a parameter typed `ValueProxy<T>` passes `ValueProxy<T>`.
+
+  ```ts
+  // Before
+  this.callfunc(mapper, Stream<T>, ValueProxy<unknown>);
+  // After
+  this.callfunc(
+    mapper,
+    Stream<T>,
+    ValueProxy<K extends keyof T ? T[K] : unknown>,
+  );
+  ```
+
 ### 🚀 Enhancements
 
 - **staged-query:** ⚠️  Drop casts, thenable queries and caller-typed schema lookups ([#91](#91))
````

`pnpm run checks`:

```
Checks: 11 passed, 0 failed.
```

The CI docs check reads only `README.md` and `docs/`, so this PR does
not change its inputs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant