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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 21 additions & 20 deletions biome-plugins/no-bare-cast.grit
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
// Recognise every `as` token except `as const`, and skip test files.
// Recognise value-level `as` casts and skip `as const` + test files.
//
// The pattern is at the top level (not wrapped in `file(...)`) so it iterates
// over every match in the file — wrapping in `file($name, $body) where { $body
// <: contains <pattern> }` only fires on the first occurrence, which would
// undercount the cast total the CI ratchet checks.
// Anchored on the `TsAsExpression` node (the AST node for a value-level
// `expr as Type` assertion) rather than a bare `$x as $t` token pattern. The
// token pattern also matched the `as` in import-specifier / re-export aliases
// (`import type { Contract as End }`, `export { X as Y }`), which are NOT
// type-assertion casts. The migration-scaffold generator (TML-2892) emits
// `import type { Contract as End }` / `as Start`, so the token pattern produced
// false positives that tripped the CI cast ratchet (scripts/lint-casts.mjs).
// `TsAsExpression` only matches real casts, so import aliases are not counted.
//
// `$filename` is biome's built-in for the current file path. Biome's GritQL
// regex matching is anchored full-match, so patterns need a `.*` prefix.
// The three exclusion regexes mirror biome's existing test-file `overrides §
// includes` patterns (`**/*.test.ts`, `**/*.test-d.ts`, `**/test/**/*.ts`).
// `$ty` is the target-type child; `as const` is excluded by the `^const$`
// guard. `$filename` is biome's built-in current-file path; biome's GritQL
// regex matching is anchored full-match, so patterns need a `.*` prefix. The
// three file-exclusion regexes mirror biome's test-file override includes
// (`**/*.test.ts`, `**/*.test-d.ts`, `**/test/**/*.ts`).
//
// The message avoids escape sequences (e.g. `\"`) because biome's GritQL
// engine appears to mis-parse subsequent characters in the string when an
// escape sequence is present (`r` → CR, `t` → TAB, `n` → LF, …).
//
// Severity is "info" so the plugin can run repo-wide without breaking
// existing builds (the codebase has thousands of pre-existing `as` casts
// that this rule reports). The CI ratchet enforces the actual gate by
// counting diagnostics from biome's JSON reporter; "info" entries still
// Severity is "info" so the plugin runs repo-wide without breaking existing
// builds (thousands of pre-existing casts). The CI ratchet enforces the gate by
// counting these diagnostics from biome's JSON reporter; "info" entries still
// appear there with the distinctive `no-bare-cast: ` prefix.
language js

`$x as $t` where {
not $t <: r"^const$",
TsAsExpression(ty=$ty) as $cast where {
not $ty <: r"^const$",
not $filename <: r".*\.(?:test|test-d)\.ts",
not $filename <: r".*/test/.*\.ts",
register_diagnostic(span=$x, message="no-bare-cast: bare `as` cast; replace with blindCast(...) or castAs(value), or rewrite to eliminate the cast", severity="info")
register_diagnostic(span=$cast, message="no-bare-cast: bare `as` cast; replace with blindCast(...) or castAs(value), or rewrite to eliminate the cast", severity="info")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# ADR 232 — A migration is authored against its start and end contract snapshots

## At a glance

A migration takes the two contract snapshots it moves between as its inputs. It assigns them to the base class and writes its operations; it states nothing else about the transition:

```ts
import endContract from './end-contract.json' with { type: 'json' };
import type { Contract as End } from './end-contract';

class M extends Migration<never, End> {
override readonly endContractJson = endContract;
override get operations() {
return [ createCollection('carts', { validator: { $jsonSchema: { /* … */ } } }), /* … */ ];
}
}
```

From those snapshots the base supplies the two things every migration needs: the transition's identity, and typed access to the contract by name (a `ContractView`, [ADR 233](<ADR 233 - ContractView is a typed by-name accessor over a contract.md>)).

## Decision

A migration is the step that moves a database from one contract to the next. The state it starts from and the state it produces are what define it, so a migration takes those two contracts — its **start** and **end** snapshots — as its inputs. Each migration directory carries them as committed, immutable artifacts (`start-contract.json` / `end-contract.json` and their `.d.ts` types); the migration assigns them to `startContractJson` / `endContractJson`.

Two things follow from holding the snapshots, and the base owns both:

1. **Identity.** `describe()` returns `{ to: endContractJson.storage.storageHash, from: startContractJson?.storage.storageHash ?? null }`. A migration's from/to identity is a property of the two states it names, read from them directly.
2. **Typed access.** The family bases (`MongoMigration`, `SqliteMigration`, `PostgresMigration`) expose lazy, memoized `startContract` / `endContract` getters — a `ContractView` over each snapshot ([ADR 233](<ADR 233 - ContractView is a typed by-name accessor over a contract.md>)) — so hand-written migration logic reaches entities by name.

A migration that carries no contract — an extension-install migration that only issues DDL, say — overrides `describe()` directly and sets no snapshot fields.

## Identity is read from the snapshot

`storage.storageHash` is a property of any contract, target-independent, so identity derivation lives on the framework `Migration` base. The runner consumes a migration through `origin` / `destination`, each `{ storageHash }`, and those project straight from the snapshots the migration ships. There is a single source for a migration's identity — the snapshots in its own directory — so its declared transition and the contracts it carries cannot disagree.

## Generated schema, hand-authored transforms

Schema operations are a pure function of the contract diff, so the migration generator emits them in full and reads nothing from the contract at author time. The snapshots are on the class for the two things a diff cannot produce: the base's identity derivation, and hand-written logic.

That second case — a data migration, such as a backfill — is where the typed `startContract` / `endContract` views earn their place. Its logic cannot be synthesized from a schema diff; an author writes it, and `this.endContract` is where that code reads entity metadata by name. The view is a convenience exactly where authoring happens by hand, not over coordinates that generated code never spells.

## Consequences

- A migration's identity is read from the contract snapshots in its own directory; there is no separate hash for an author to keep in step.
- A migration's authored scaffold is a function of its snapshots and its operations, so re-emitting the scaffold leaves the migration's behaviour — its `ops.json` / `migration.json` — unchanged.
- Every migration has typed contract access; the type flows from the per-migration `end-contract.d.ts` through the family base's view getter.
- The base carries optional snapshot fields and a concrete `describe()` that a subclass may override, so a migration with no contract is still a valid migration.

## Alternatives considered

- **A hand-written `describe()` carrying the from/to hashes as literals.** The identity restated as strings beside the file that already holds the contracts those strings summarize — two sources for one fact, kept in step by the author. Rejected in favour of deriving identity from the snapshots.
- **A migration that references a single shared contract rather than a per-migration snapshot pair.** A migration would then read the current contract, not the one it was authored against, and its meaning would drift as the schema evolves. Rejected: a migration is a fixed transition and must name both endpoints as immutable snapshots.

## References

- [ADR 233 — ContractView is a typed, by-name accessor over a contract](<ADR 233 - ContractView is a typed by-name accessor over a contract.md>) (the typed access this migration model exposes).
- ADR 224 — Namespace concretions address entities by coordinate.
- ADR 223 — Target-owned default namespace.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# ADR 233 — ContractView is a typed, by-name accessor over a contract

## At a glance

A `ContractView` reads a contract's entities by name, with the default namespace unwrapped:

```ts
import { MongoContractView } from '@prisma-next/family-mongo/ir';

const view = MongoContractView.fromJson<Contract>(contractJson);
view.collection.carts.validator;
```

instead of the entity's full storage coordinate:

```ts
contractJson.storage.namespaces.__unbound__.entries.collection.carts.validator;
```

The view is a **superset of the contract** — usable anywhere the contract is — so a single value serves both roles.

## Decision

A contract addresses its entities by storage coordinate: `storage.namespaces.<id>.entries.<kind>.<name>`. That coordinate is exact and correct for the serialized artifact, but it is not a reading surface — it spells a namespace-binding sentinel, an `entries` dictionary, and a kind key, none of which mean anything to code that just wants "the `carts` collection."

`ContractView` is a separate object that presents the same entities by name. It is:

- **A superset of the contract.** The view carries the whole contract plus by-name accessors, so a caller holds one value that is both the contract and the ergonomic surface over it.
- **Per target, via a `from` / `fromJson` factory.** `from(contract)` wraps an already-deserialized contract; `fromJson(json)` deserializes and wraps in one step. The view type is generic over the contract, so access stays fully typed against the specific contract passed.
- **Default-namespace-unwrapping.** A single-namespace target reads its entities flat; a multi-namespace target keeps the namespace as a coordinate.

The contract type itself stays a raw mirror of the serialized form. The view is a projection computed from whatever contract it is given, so the emitter and serializers own no part of it — nothing about the accessor is baked into the emitted artifact.

## The per-target shape

Each target's view unwraps that target's default namespace and surfaces its entity-kind slots:

| Target | Access | Namespace handling |
| --- | --- | --- |
| Mongo | `view.collection.<name>` | single namespace unwrapped to the root |
| SQLite | `view.table.<name>`, `view.valueSet.<name>` | single namespace unwrapped to the root |
| Postgres | `view.namespace.<schema>.table.<name>` | schemas addressed under a fixed `namespace` member |

Pack-contributed entity kinds (beyond a family's built-in kinds) remain reachable under `entries` on the projected namespace, keyed by the kind's registered name.

## Schemas are addressed under a member, not the contract root

The view shares one namespace projection with the runtime `enums` surface: a namespace-keyed map, `view.namespace.<schema>`, with the default namespace unwrapped for single-namespace targets. A multi-namespace target keeps each schema as a coordinate under the fixed `namespace` member rather than lifting schema names onto the view's root.

Schema names are user-chosen. A schema placed at the root and named like a contract field — `storage`, `domain` — would shadow that field, and because the view is a structural superset of the contract, the type system would not catch it. Addressing schemas under `namespace` makes the collision impossible: the only keys at the root are the contract's own fields and a family's fixed entity-kind slots, all known ahead of time. A single-namespace target still reads its entities flat, because it has exactly one namespace to unwrap and no schema coordinate to disambiguate.

## Consequences

- A reader of a contract reaches entities by name without spelling the namespace sentinel, the `entries` dictionary, or a kind key.
- The view is substitutable for the contract, so one value carries both the raw contract and the by-name surface.
- The projection is single-sourced with the runtime `enums` surface, so the two present namespaces the same way.
- A view over a contract that lacks the expected default namespace fails loudly at construction rather than yielding an undefined slot.

## Alternatives considered

- **An accessor method on the contract type itself.** The author-facing contract is data-only — its emitted `.d.ts` declares no methods — so a getter there is invisible to consumer code, and it would couple the emitter to a convenience concern. Rejected in favour of a separate view object.
- **Denormalised accessor data emitted into the contract artifact.** Duplicates every entity in the canonical artifact and invites drift between the copies. Rejected.
- **Schema names at the view's root** (a flat `view.<schema>.…`). A schema named like a contract field silently shadows it, uncaught by the type system. Rejected in favour of the `namespace` member.
- **Free helper functions over the contract** (`tables(contract)`, `collections(contract)`). Workable, but scatters the surface across many functions and gives the projection no single home. Rejected in favour of one view object per target.
- **A class whose static factory returns a non-instance.** A `class` existing only to host a `static from()` that returns a plain projection shape adds a type that is never instantiated. Rejected in favour of a plain `{ from, fromJson }` factory.

## References

- ADR 232 — A migration is authored against its start and end contract snapshots (the view's consumer).
- ADR 224 — Namespace concretions address entities by coordinate (the raw coordinate the view projects from).
- ADR 225 — Three-layer extensibility for pack-contributed entity kinds (the `entries` dictionary the view reads).
- ADR 223 — Target-owned default namespace (the default-namespace sentinel the view unwraps).
- [`docs/architecture docs/patterns/interface-plus-factory.md`](../patterns/interface-plus-factory.md) — the `from` / `fromJson` factory shape.
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { MigrationCLI } from '@prisma-next/cli/migration-cli';
import { Migration } from '@prisma-next/family-mongo/migration';
import { createIndex } from '@prisma-next/target-mongo/migration';
import type { Contract as End } from './end-contract';
import endContract from './end-contract.json' with { type: 'json' };

class InitialMigration extends Migration {
override describe() {
return {
from: null,
to: 'sha256:2827cbad7293fe13a4fb2aab60a55d3cddd856a86d1f6ccea6e11519faacff92',
};
}
class InitialMigration extends Migration<never, End> {
override readonly endContractJson = endContract;

override get operations() {
return [createIndex('users', [{ field: 'email', direction: 1 }], { unique: true })];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
import { MigrationCLI } from '@prisma-next/cli/migration-cli';
import { Migration } from '@prisma-next/family-mongo/migration';
import { collMod } from '@prisma-next/target-mongo/migration';
import type { Contract as End } from './end-contract';
import endContract from './end-contract.json' with { type: 'json' };
import type { Contract as Start } from './start-contract';
import startContract from './start-contract.json' with { type: 'json' };

class M extends Migration {
override describe() {
return {
from: 'sha256:2827cbad7293fe13a4fb2aab60a55d3cddd856a86d1f6ccea6e11519faacff92',
to: 'sha256:250af57beb0580c2c9562789d5d05ae39bcfabd08b2eca8367f59a70fa724b7d',
};
}
class M extends Migration<Start, End> {
override readonly startContractJson = startContract;
override readonly endContractJson = endContract;

override get operations() {
return [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
import { MigrationCLI } from '@prisma-next/cli/migration-cli';
import { Migration } from '@prisma-next/family-mongo/migration';
import { createIndex } from '@prisma-next/target-mongo/migration';
import type { Contract as End } from './end-contract';
import endContract from './end-contract.json' with { type: 'json' };
import type { Contract as Start } from './start-contract';
import startContract from './start-contract.json' with { type: 'json' };

class M extends Migration {
override describe() {
return {
from: 'sha256:250af57beb0580c2c9562789d5d05ae39bcfabd08b2eca8367f59a70fa724b7d',
to: 'sha256:ecc554e5f2f05ec120f8fef5ddf536286471edd3de11b8a906ba70e71f5e5df3',
};
}
class M extends Migration<Start, End> {
override readonly startContractJson = startContract;
override readonly endContractJson = endContract;

override get operations() {
return [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,11 @@ import type { SqlMigrationPlanOperation } from '@prisma-next/family-sql/control'
import { Migration, MigrationCLI, rawSql } from '@prisma-next/target-postgres/migration';
import type { PostgresPlanTargetDetails } from '@prisma-next/target-postgres/planner-target-details';
import { AUDIT_BASELINE_INVARIANT_ID, AUDIT_EVENT_TABLE } from '../../src/constants';
import type { Contract as End } from './end-contract';
import endContract from './end-contract.json' with { type: 'json' };

export default class M extends Migration {
override describe() {
return {
from: null,
to: 'sha256:b0d547223488b4a8cea642a0bb2cc8e8f6cd9b2a6e490f23832865146ac51468',
};
}
export default class M extends Migration<never, End> {
override readonly endContractJson = endContract;

override get operations(): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
return [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,11 @@ import type { SqlMigrationPlanOperation } from '@prisma-next/family-sql/control'
import { Migration, MigrationCLI, rawSql } from '@prisma-next/target-postgres/migration';
import type { PostgresPlanTargetDetails } from '@prisma-next/target-postgres/planner-target-details';
import { FEATURE_FLAG_TABLE, FEATURE_FLAGS_BASELINE_INVARIANT_ID } from '../../src/constants';
import type { Contract as End } from './end-contract';
import endContract from './end-contract.json' with { type: 'json' };

export default class M extends Migration {
override describe() {
return {
from: null,
to: 'sha256:6759a7591f2bdf9b5c20fcbf2b02dbf56956c7762cef663c5e8e2b6779057cf4',
};
}
export default class M extends Migration<never, End> {
override readonly endContractJson = endContract;

override get operations(): readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[] {
return [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
#!/usr/bin/env -S node
import { col, Migration, MigrationCLI, primaryKey } from '@prisma-next/postgres/migration';
import type { Contract as End } from './end-contract';
import endContract from './end-contract.json' with { type: 'json' };

export default class M extends Migration {
override describe() {
return {
from: null,
to: 'sha256:789dd79ab5ab725be1b6ced088109b803a4d62f9874f932eb384a868d94360a4',
};
}
export default class M extends Migration<never, End> {
override readonly endContractJson = endContract;

override get operations() {
return [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
#!/usr/bin/env -S node
import { col, Migration, MigrationCLI } from '@prisma-next/postgres/migration';
import type { Contract as End } from './end-contract';
import endContract from './end-contract.json' with { type: 'json' };
import type { Contract as Start } from './start-contract';
import startContract from './start-contract.json' with { type: 'json' };

export default class M extends Migration {
override describe() {
return {
from: 'sha256:789dd79ab5ab725be1b6ced088109b803a4d62f9874f932eb384a868d94360a4',
to: 'sha256:93be6c200743261baf55f0586b1380a1c0ade3c48730c09a8fec71ba419c2464',
};
}
export default class M extends Migration<Start, End> {
override readonly startContractJson = startContract;
override readonly endContractJson = endContract;

override get operations() {
return [this.addColumn({ schema: '__unbound__', table: 'user', column: col('phone', 'text') })];
Expand Down
Loading
Loading