Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
87 changes: 87 additions & 0 deletions .agents/rules/jsr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
trigger: glob
globs: 'projects/api/**'
description: 'How @trakt/api keeps a 100 JSR score - static types, symbol docs, and provenance publishing. Read when touching projects/api.'
applyTo: 'projects/api/**'
---

# Keeping @trakt/api at a 100 JSR score

The JSR score is scored per published version. Every rule below must hold in the
version you publish, or the score drops. Verify with
`cd projects/api && deno task publish:check` (expect `Success`, no
`--allow-slow-types`) before shipping.

## 1. No slow types (static declarations)

The contracts are Zod + ts-rest, whose fully-inferred types are JSR "slow
types". We do NOT publish with `--allow-slow-types`. Instead:

- `mod.ts` is the published entry. Runtime comes from `./src/index.ts`; the
public types come from precomputed `./types/*.d.ts` via a
`// @ts-types="./types/index.d.ts"` directive.
- `deno task build:types` (scripts/build-types.ts) generates `./types` with
dnt + tsc, then rewrites relative import extensions to `.d.ts`. `types/` is
gitignored and force-included via `publish.include`; it is regenerated by the
`publish` / `publish:check` tasks.

**A few aggregate types exceed TypeScript's own declaration-serialization limit
(TS7056) as a single literal.** They are kept precise by composing from `typeof`
references (or ts-rest's own `router` return type). Do not "simplify" these
annotations - they are load-bearing:

- `traktContract` has an explicit `TraktContract` type of `typeof <domain>`
references. **When you add a new domain router, add it to this alias**, or
`build:types` fails with TS7056.
- `users` uses `UsersInput` +
`UsersRouter = ReturnType<typeof builder.router<...>>` with an
`as unknown as UsersRouter` cast. Keep the internal route groups
(`GLOBAL_LEVEL`, `ENTITY_LEVEL`, `syncs`, `plex`) non-exported.
- The query factories (`extendedQuerySchemaFactory`, `searchTypeParamFactory`,
`trendingSearchTypeParamFactory`) have explicit `z.ZodObject<{...}>` returns
(avoids TS5088 cyclic).
- `builder` is typed `ReturnType<typeof initContract>` (avoids TS4023).

If a new symbol trips TS7056/TS5088/TS4023 during `build:types`, give it an
explicit type composed of named references - never fall back to
`--allow-slow-types`.

## 2. Docs for every exported symbol (>= 80%, keep it 100%)

JSR reads JSDoc from the symbol's type source. Because we ship generated
declarations, **source JSDoc is carried into `./types` and counted** - so every
exported symbol needs a `/** ... */` above it.

- **Every new `export const` / `export type` / `export function` /
`export class` / `export enum` gets a JSDoc comment.** One tight line stating
what it is is enough (`/** Zod schema for the movie response. */`).
- Follow the existing naming-derived phrasing: `...ResponseSchema` -> "Zod
schema for the X response.", `XResponse` (type) -> "The X response payload.",
routers -> "ts-rest contract for the `/x` endpoints.", etc.
- The `mod.ts` module doc (with `@module` and an `@example` code block) and the
package `README.md` are required - do not remove them. Keep the example
compiling.
- Measure: `cd projects/api && deno doc src/index.ts` then confirm every
"Defined in" block has a doc line (target 100%).

## 3. Provenance (publish only via CI)

Provenance requires publishing through the GitHub Actions workflow with OIDC
(`.github/workflows/publish.yml`), which records a public transparency-log
entry.

- **Never publish with a manual local `deno publish`** - that produces no
provenance and drops the score.
- Publish by cutting a GitHub release (or `workflow_dispatch` on the publish
workflow). The workflow runs `deno task build:types` then `deno publish`.
- **Bump `version` in `projects/api/deno.json` first** - JSR versions are
immutable; you cannot re-publish an existing version to fix its score.

## Pre-publish checklist

1. New/changed exports all have JSDoc.
2. New domain routers added to the `TraktContract` alias.
3. `deno task publish:check` -> `Success` (no `--allow-slow-types`).
4. `deno doc src/index.ts` symbol coverage is 100%.
5. `version` bumped in `projects/api/deno.json`.
6. Publish via the GitHub workflow, not locally.
66 changes: 66 additions & 0 deletions .agents/rules/project.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
trigger: glob
globs: '**'
description: 'Core project overview, structure, tooling, and commit standards for the trakt-api monorepo. Apply to all files.'
applyTo: '**'
---

# Project Guidelines

## Tech Stack

Deno workspace (monorepo). TypeScript. The publishable artifact is `@trakt/api`
in `projects/api/` - a fully typed [ts-rest](https://ts-rest.com) contract +
client for the Trakt API, backed by Zod schemas, published to
[JSR](https://jsr.io/@trakt/api).

## Project Structure

```
projects/
api/ # @trakt/api - the published package
mod.ts # published entry (facade: runtime from src, types from ./types)
src/ # implementation: contracts, schemas, client
index.ts # barrel + client (traktApi, traktApiFactory)
contracts/ # one dir per domain (movies, shows, users, ...)
_internal/ # shared schemas, builder, request/response building blocks
traktContract.ts # aggregate contract of all domains
types/ # GENERATED static .d.ts (gitignored, built before publish)
scripts/ # build tooling (build-types.ts)
openapi/ # generates OpenAPI from the contract (runtime consumer)
playground/ # local scratch client
```

## Tooling

- `deno task install` - frozen install (matches lockfile; run before CI work).
- `deno fmt` / `deno lint` - formatting and lint (config in root + api
deno.json).
- `deno task openapi:validate` - validates the generated OpenAPI.
- `cd projects/api && deno task test` - package tests.
- Never hand-edit `deno.lock` for build-only deps; `build:types` runs with
`--no-lock` so it never dirties it.

## Commit Standards

- **Conventional Commits** (enforced by commitlint on PRs): `feat:`, `fix:`,
`chore:`, `docs:`, `refactor:`, `test:`, `perf:`. Scope with `(api)` when the
change is in the package, e.g. `feat(api): add smart lists endpoints`.
- **Version bumps are their own `chore(api): bump ...` commit.** JSR versions
are immutable - every publish needs a new version.
- **No `Co-Authored-By` trailers**, no "generated with" footers.
- **No em-dashes or en-dashes** in commit messages, PR bodies, or review
replies - plain hyphens only.

## Branching and PRs

- Never commit to `master`. Branch first, open a PR, let CI gate it.
- Keep the PR description in sync with the branch as scope changes.
- Address review feedback by amending the origin commit (fixup + autosquash),
not by stacking "address review" commits.

## Tone

Direct, concise, technical. State assumptions before non-trivial changes.
Surgical diffs - every changed line traces to the request; do not "improve"
adjacent code. Match existing style even if you would do it differently.
56 changes: 56 additions & 0 deletions .agents/rules/schemas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
trigger: glob
globs: 'projects/api/src/contracts/**'
description: 'Conventions for authoring ts-rest + Zod contract schemas. The OpenAPI spec and consumer models/clients are generated from these, so schema shape is a public artifact. Read when editing contracts.'
applyTo: 'projects/api/src/contracts/**'
---

# Schema authoring

Conventions for authoring the ts-rest + Zod API contract. The OpenAPI spec and
consumer models/clients are generated from these schemas, so schema shape is a
public artifact, not just internal typing.

## Response schemas

### Polymorphic / multi-shape responses: one flat object, all fields nullish

When an endpoint returns entries of more than one entity shape - a merged feed
(e.g. calendar `media` / `releases/hot` returning movies AND episodes), or any
route whose response varies by a `type=movie|show|episode|...` param - model the
response as a SINGLE object with **every shape-specific field nullish**. Do NOT
use `z.union([...])`.

```ts
// ❌ BAD: union -> OpenAPI `oneOf` -> codegen emits a model with ALL fields
// required, so consumers get a wrong schema.
export const feedResponseSchema = z.union([movieEntrySchema, showEntrySchema]);

// ✅ GOOD: one flat object, shape-specific fields nullish -> codegen emits a
// correct all-optional model. Discriminate by shape at runtime.
export const feedResponseSchema = z.object({
released: z.string().nullish(),
movie: movieResponseSchema.nullish(),
first_aired: z.string().nullish(),
episode: episodeResponseSchema.nullish(),
show: showResponseSchema.nullish(),
});
```

**Why:** OpenAPI codegen turns a union `oneOf` into a single model with every
member's fields marked required, so a downstream consumer sees `movie`, `show`,
`episode`, `season`, etc. all as non-null and its generated model is wrong.
Nullish fields on one object generate a correct optional-field model; the caller
null-checks (`if (entry.movie) ...`) to discriminate.

Single-shape responses stay fully required - only widen to nullish the fields
that are genuinely absent for some variant.

Reference:
`projects/api/src/contracts/calendars/schema/response/hotReleaseResponseSchema.ts`.

## JSR score

Every exported schema and type is a public symbol on JSR and must carry a JSDoc
`/** ... */`, and new domain routers must be added to the `traktContract` type
alias. See `jsr.md` for the full rules that keep the score at 100.
36 changes: 36 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: The Rite of Ascension aka Publish to JSR

on:
release:
types: [published]
workflow_dispatch:

concurrency:
group: publish-jsr
cancel-in-progress: false

jobs:
publish:
name: The Offering aka Publish @trakt/api
runs-on: ubuntu-latest
permissions:
contents: read
# Required for JSR provenance via OIDC.
id-token: write
steps:
- name: The Oracle's Gaze aka Checkout Code
uses: actions/checkout@v4

- name: The Summoning aka Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x

- name: The Sacred Assembly aka Install Dependencies
run: deno task install

- name: The Transcription aka Generate Static Types
run: cd projects/api && deno task build:types

- name: The Offering aka Publish
run: cd projects/api && deno publish
50 changes: 12 additions & 38 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,43 +1,17 @@
# trakt-api agent rules
# Always-loaded core (small, project-wide)

Conventions for authoring the ts-rest + zod API contract. The OpenAPI spec and
consumer models/clients are generated from these schemas, so schema shape is a
public artifact, not just internal typing.
@.agents/rules/project.md

## Response schemas
# Domain rules - load on demand

### Polymorphic / multi-shape responses: one flat object, all fields nullish
Domain-specific rules are NOT auto-imported, to keep the baseline small. Read
them when the work touches the matching area (CLAUDE.md routes the mapping; the
rule files live at `.agents/rules/`):

When an endpoint returns entries of more than one entity shape - a merged feed
(e.g. calendar `media` / `releases/hot` returning movies AND episodes), or any
route whose response varies by a `type=movie|show|episode|...` param - model the
response as a SINGLE object with **every shape-specific field nullish**. Do NOT
use `z.union([...])`.
- `jsr.md` - anything under `projects/api/`: static types, symbol docs, and
provenance publishing that keep the `@trakt/api` JSR score at 100.
- `schemas.md` - authoring ts-rest + Zod contract schemas under
`projects/api/src/contracts/` (schema shape is a generated public artifact).

```ts
// ❌ BAD: union -> OpenAPI `oneOf` -> codegen emits a model with ALL fields
// required, so consumers get a wrong schema.
export const feedResponseSchema = z.union([movieEntrySchema, showEntrySchema]);

// ✅ GOOD: one flat object, shape-specific fields nullish -> codegen emits a
// correct all-optional model. Discriminate by shape at runtime.
export const feedResponseSchema = z.object({
released: z.string().nullish(),
movie: movieResponseSchema.nullish(),
first_aired: z.string().nullish(),
episode: episodeResponseSchema.nullish(),
show: showResponseSchema.nullish(),
});
```

**Why:** OpenAPI codegen turns a union `oneOf` into a single model with every
member's fields marked required, so a downstream consumer sees `movie`, `show`,
`episode`, `season`, etc. all as non-null and its generated model is wrong.
Nullish fields on one object generate a correct optional-field model; the caller
null-checks (`if (entry.movie) ...`) to discriminate.

Single-shape responses stay fully required - only widen to nullish the fields
that are genuinely absent for some variant.

Reference:
`projects/api/src/contracts/calendars/schema/response/hotReleaseResponseSchema.ts`.
Read with the Read tool when the task enters the domain. Re-read after long gaps
if context was compacted.
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Before implementing anything, identify which area you are working in and read
the corresponding rule file from `.agents/rules/` (only the core rules auto-load
via AGENTS.md; domain rules load on demand):

- Published package (`projects/api/**`): read `jsr.md` - keeps the `@trakt/api`
JSR score at 100 (static types, symbol docs, provenance publishing).
- Contract schemas (`projects/api/src/contracts/**`): also read `schemas.md`.
- Everything else: `project.md` (always-on baseline, already loaded as core).

@AGENTS.md
6 changes: 6 additions & 0 deletions projects/api/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
This is the published `@trakt/api` package. Keeping its JSR score at 100 has
hard rules - read them before changing exports, schemas, or the publish flow:

@../../.agents/rules/jsr.md

@../../.agents/rules/schemas.md
65 changes: 65 additions & 0 deletions projects/api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# @trakt/api

Fully typed [ts-rest](https://ts-rest.com) contract and client for the
[Trakt API](https://trakt.docs.apiary.io), backed by Zod schemas.

`traktApi()` returns a client with precise request/response types for every
endpoint. The individual Zod schemas and inferred model types are also exported
for validation and reuse.

## Install

```sh
deno add jsr:@trakt/api
```

## Usage

```ts
import { traktApi } from '@trakt/api';

const client = traktApi({ apiKey: '<client-id>' });

const res = await client.movies.summary({ params: { id: 'tron-legacy-2010' } });
if (res.status === 200) {
console.log(res.body.title); // fully typed
}
```

### Environment

By default the client targets production. Pass an `environment` to override:

```ts
import { Environment, traktApi } from '@trakt/api';

const staging = traktApi({
apiKey: '<client-id>',
environment: Environment.staging,
});
```

### Cancellation

Pass `cancellable: true` so a newer request with the same `cancellationId`
aborts the in-flight one:

```ts
const client = traktApi({ apiKey: '<client-id>', cancellable: true });
```

## Schemas and types

Every endpoint's request and response is a Zod schema, and the inferred model
types are exported alongside the contract:

```ts
import { type MovieResponse, movieResponseSchema } from '@trakt/api';

const movie: MovieResponse = movieResponseSchema.parse(await someFetch());
```

## Contract

The raw ts-rest contract is exported as `traktContract` for use with any
ts-rest-compatible server or client.
Loading
Loading