Skip to content
Open
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
50ad3c2
Extension contract spaces project plan
wmadden May 7, 2026
a2c4fd2
docs(extension-contract-spaces): refine spec with design discussion o…
wmadden May 7, 2026
7b726a5
Pin extension contract.json + head ref on disk in the user repo
wmadden May 7, 2026
a2b73bb
Finalise plan: drop arktype-json, draft sub-specs, lock decisions
wmadden May 7, 2026
3825e90
Assign reviewer (William Madden); remove TBD
wmadden May 7, 2026
001d38f
feat(family-sql): add contractSpace descriptor field (T1.2)
wmadden May 7, 2026
df5917b
feat(extension-test-contract-space): scaffold synthetic test extensio…
wmadden May 7, 2026
c44160a
fix(extension-test-contract-space): use brand helpers instead of as-c…
wmadden May 7, 2026
16085c6
feat(framework-sql): per-space marker schema migration (T1.1)
wmadden May 7, 2026
4534b79
test(sql-runtime): align mock marker SQL with per-space schema (F2)
wmadden May 7, 2026
2a99d01
feat(migration-tools): producer-side per-space helpers (T1.3, T1.6, T…
wmadden May 7, 2026
cdba691
test(migration-tools): assert planAllSpaces rejects duplicates before…
wmadden May 7, 2026
0a9e2b1
feat(migration-tools): per-space pinned artefacts, runner ordering, a…
wmadden May 7, 2026
074af06
docs(extension-contract-spaces): mark T1.4 + T1.5 + T1.8 + T1.10b as …
wmadden May 7, 2026
6ca74d3
feat(migration-tools): drift detection helpers for per-space migrate …
wmadden May 7, 2026
90aaef9
docs(extension-contract-spaces): mark T1.9 completed; M1 SATISFIED-ca…
wmadden May 7, 2026
6b46ab9
docs(extension-contract-spaces): close M1 sub-spec doc-pass — reflect…
wmadden May 7, 2026
bca1222
docs(test-contract-space): point README at durable reference
wmadden May 8, 2026
31f773d
refactor(migration-tools): brand ValidSpaceId for compile-time tracking
wmadden May 8, 2026
a210c79
test(migration-tools): drop redundant dynamic mkdir import
wmadden May 8, 2026
d0f0bb5
refactor(migration-tools): export MANIFEST_FILE for cross-helper use
wmadden May 8, 2026
8e84080
refactor(migration-tools): detect migration dirs by manifest presence…
wmadden May 8, 2026
f21a3f5
refactor(extension-contract-spaces): relocate test-contract-space fix…
wmadden May 8, 2026
1a7cb82
docs(extension-contract-spaces): add M1-cleanup milestone; reflect T-…
wmadden May 8, 2026
9e39382
refactor(framework-components): canonicalize APP_SPACE_ID under contr…
wmadden May 8, 2026
68ebbeb
refactor(framework-components): hoist contract-space identity types t…
wmadden May 8, 2026
3b129a7
docs(extension-contract-spaces): reflect F3/F4 closure in plan + spec
wmadden May 8, 2026
15e0534
refactor(target-postgres,target-sqlite): replace transitional marker-…
wmadden May 8, 2026
789f7c4
docs(extension-contract-spaces): close M1-cleanup milestone — SATISFI…
wmadden May 8, 2026
a9697ba
docs(extension-contract-spaces): reopen M1-cleanup for F6 (Authored* …
wmadden May 8, 2026
f8649ba
refactor(framework-components,migration-tools): flatten contract-spac…
wmadden May 8, 2026
ac2157d
docs(extension-contract-spaces): close T-cleanup.5; spec rename picku…
wmadden May 8, 2026
ee05b2b
docs(extension-contract-spaces): M1-cleanup re-SATISFIED at R4 (F6 cl…
wmadden May 8, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const contractJsonPath = join(exampleDir, 'src', 'prisma', 'contract.json');
const TEST_SCHEMA_SQL = `
create schema if not exists prisma_contract;
create table if not exists prisma_contract.marker (
id smallint primary key default 1,
space text not null primary key default 'app',
core_hash text not null default '',
profile_hash text not null default '',
contract_json jsonb,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"lint:fix:unsafe": "pnpm biome check --write --unsafe .",
"lint:packages": "turbo run lint --filter='!./examples/**'",
"lint:examples": "turbo run lint --filter='./examples/**'",
"lint:deps": "depcruise --config dependency-cruiser.config.mjs packages && node scripts/lint-framework-target-imports.mjs",
"lint:deps": "depcruise --config dependency-cruiser.config.mjs packages && node scripts/lint-framework-target-imports.mjs && node scripts/lint-app-space-id.mjs",
"lint:rules": "node scripts/validate-rules.mjs",
"lint:rules:footprint": "node scripts/rules-footprint.mjs --check",
"rules:footprint": "node scripts/rules-footprint.mjs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,61 @@ import type { Result } from '@prisma-next/utils/result';
import type { TargetBoundComponentDescriptor } from '../shared/framework-components';
import type { ControlDriverInstance, ControlFamilyInstance } from './control-instances';

// ============================================================================
// Migration Package Metadata
// ============================================================================

/**
* Planner provenance recorded inside {@link MigrationMetadata}.
*
* `used` / `applied` track which migration hints the planner consulted
* vs. which it actually applied during emission; `plannerVersion`
* pins the planner build that produced the migration so future
* verification passes can recognise plans authored against an older
* planner.
*/
export interface MigrationHints {
readonly used: readonly string[];
readonly applied: readonly string[];
readonly plannerVersion: string;
}

/**
* In-memory migration metadata envelope. Every migration is
* content-addressed: the `migrationHash` is a hash over the metadata
* envelope plus the operations list, computed at write time. There is no
* draft state — a migration directory either exists with fully attested
* metadata or it does not.
*
* When the planner cannot lower an operation because of an unfilled
* `placeholder(...)` slot, the migration is still written with
* `migrationHash` hashed over `ops: []`. Re-running self-emit after the
* user fills the placeholder produces a *different* `migrationHash`
* (committed to the real ops); this is intentional.
*
* The on-disk JSON shape in `migration.json` matches this type
* field-for-field — `JSON.stringify(metadata, null, 2)` is the canonical
* writer output (defined in `@prisma-next/migration-tools/io`).
*/
export interface MigrationMetadata {
readonly migrationHash: string;
readonly from: string | null;
readonly to: string;
readonly fromContract: Contract | null;
readonly toContract: Contract;
readonly hints: MigrationHints;
readonly labels: readonly string[];
/**
* Sorted, deduplicated list of `invariantId`s declared by the
* migration's data-transform ops. Always present; an empty array
* means the migration has no routing-visible data transforms.
*/
readonly providedInvariants: readonly string[];
readonly authorship?: { readonly author?: string; readonly email?: string };
readonly signature?: { readonly keyId: string; readonly value: string } | null;
readonly createdAt: string;
}

// ============================================================================
// Operation Classes and Policy
// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Contract } from '@prisma-next/contract/types';
import type { MigrationMetadata, MigrationPlanOperation } from './control-migration-types';

/**
* Canonical control-plane identifiers for contract spaces.
*
* A contract space is the disjoint `(contract.json, migration-graph)` unit
* the per-space planner / runner / verifier (project: extension contract
* spaces, TML-2397) operates on. The application owns one well-known
* space — the value below — and each loaded extension that contributes
* schema owns a uniquely-named space.
*
* Lives in `framework-components/control` so every layer that has to
* reason about space identity (the migration tooling, the SQL runtime's
* marker reader, target-side statement builders, target-side adapters)
* can import a single value rather than duplicating the literal. Raw
* `'app'` string literals in framework / target / runtime / adapter
* source code are forbidden and policed by
* `scripts/lint-app-space-id.mjs` (wired into `pnpm lint:deps`).
*
* @see specs/framework-mechanism.spec.md § 3 — Layout convention (γ).
*/
export const APP_SPACE_ID = 'app' as const;

/**
* Pinned head ref for a contract space — the `(hash, invariants)` tuple
* a runner targets when applying that space's migration graph. Identical
* in shape to the on-disk `migrations/<space-id>/refs/head.json` the
* framework writes per loaded extension, and to the app-space
* `<projectRoot>/refs/head.json`. Family-agnostic: SQL, Mongo, and any
* future family share the same head-ref shape.
*
* @see specs/framework-mechanism.spec.md § 1.
*/
export interface ContractSpaceHeadRef {
readonly hash: string;
readonly invariants: readonly string[];
}

/**
* In-memory authored migration package as published by an extension's
* descriptor module (or by the app-space planner before emission).
* Mirrors the on-disk
* {@link import('@prisma-next/migration-tools/package').MigrationPackage}
* shape minus `dirPath` — at descriptor / planner construction time the
* package has not yet been materialised to the user's repo, so there is
* no path to record.
*
* The framework's pinned-artefact emission step
* (`writeAuthoredMigrationPackage` in `@prisma-next/migration-tools/io`)
* materialises each package into `migrations/<space-id>/<dirName>/`.
*
* @see specs/framework-mechanism.spec.md § 1, § 3.
*/
export interface AuthoredMigrationPackage {
readonly dirName: string;
readonly metadata: MigrationMetadata;
readonly ops: readonly MigrationPlanOperation[];
}

/**
* In-memory contract-space view a schema-contributing extension
* publishes through its descriptor module: the canonical contract value,
* the migration graph authored against it, and the pinned head ref. The
* framework reads this value only at authoring time (during `migrate`);
* apply / verify paths read the user's repo
* (`migrations/<space-id>/...`) instead.
*
* Generic over the storage block so SQL extensions can specialise
* `AuthoredContractSpace<SqlStorage>` while the framework type stays
* family-agnostic.
*
* @see specs/framework-mechanism.spec.md § 1.
*/
export interface AuthoredContractSpace<TContract extends Contract = Contract> {
readonly contractJson: TContract;
readonly migrations: readonly AuthoredMigrationPackage[];
readonly headRef: ContractSpaceHeadRef;
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type {
ControlTargetInstance,
} from '../control/control-instances';
export type {
MigrationHints,
MigrationMetadata,
MigrationOperationClass,
MigrationOperationPolicy,
MigrationPlan,
Expand Down Expand Up @@ -74,6 +76,12 @@ export type {
SchemaTreeVisitor,
} from '../control/control-schema-view';
export { SchemaTreeNode } from '../control/control-schema-view';
export type {
AuthoredContractSpace,
AuthoredMigrationPackage,
ContractSpaceHeadRef,
} from '../control/control-spaces';
export { APP_SPACE_ID } from '../control/control-spaces';
export type {
AssembledAuthoringContributions,
ControlStack,
Expand Down
4 changes: 4 additions & 0 deletions packages/1-framework/3-tooling/migration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@
"types": "./dist/exports/migration.d.mts",
"import": "./dist/exports/migration.mjs"
},
"./spaces": {
"types": "./dist/exports/spaces.d.mts",
"import": "./dist/exports/spaces.mjs"
},
"./package.json": "./package.json"
},
"repository": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { errorDuplicateSpaceId } from './errors';
import { APP_SPACE_ID } from './space-layout';

/**
* Per-space input the runner consumes when applying a migration.
*
* The shape is target-agnostic: callers (today the SQL family; later
* any other family) bind `TOp` to their own per-target operation type
* (e.g. `SqlMigrationPlanOperation<TTargetDetails>` for the SQL family)
* and the helper preserves it through the concatenation.
*
* - `migrationDirectory` is the on-disk migration directory for the
* space — `<projectRoot>/migrations` for `'app'` and
* `<projectRoot>/migrations/<space-id>` for an extension space.
* - `currentMarkerHash` and `currentMarkerInvariants` are the values
* read from the `prisma_contract.marker` row keyed by `space = <space-id>`
* (T1.1). `null` hash = no marker row yet.
* - `path` is the per-space operation list resolved from
* `findPathWithDecision(currentMarker, ref.hash, effectiveRequired)`
* per ADR 208, materialised against the on-disk migration packages.
*
* @see specs/framework-mechanism.spec.md § 4 — Runner.
*/
export interface SpaceApplyInput<TOp> {
readonly spaceId: string;
readonly migrationDirectory: string;
readonly currentMarkerHash: string | null;
readonly currentMarkerInvariants: readonly string[];
readonly path: readonly TOp[];
}

/**
* Order a set of per-space apply inputs into the canonical cross-space
* sequence the runner applies under a single transaction.
*
* Cross-space ordering convention (sub-spec § 4):
*
* 1. **Extension spaces first**, alphabetically by `spaceId`.
* 2. **App space last** — only one `'app'` entry expected, at most.
*
* Rationale: extensions install their own structural objects (types,
* functions, helper tables) before the app's structural ops reference
* them. Putting app-space last lets app-space ops freely depend on any
* extension-space declaration in the same transaction.
*
* Determinism (NFR6): the output order is independent of the input
* order, so two callers with the same set of `extensionPacks` produce
* identical apply sequences.
*
* Atomicity: rejects duplicate `spaceId`s with
* `MIGRATION.DUPLICATE_SPACE_ID` before producing any output. This
* mirrors {@link import('./plan-all-spaces').planAllSpaces} so the
* planner-side and runner-side helpers reject malformed inputs the same
* way (callers don't need a separate dedup pass).
*
* Synchronous, pure, no I/O: callers resolve marker rows and `path`
* before invoking this helper. The actual DB application — driving the
* transaction, committing marker writes, recording the per-space marker
* rows — happens at the SQL-family consumption site (per the
* helper-location convention from R3).
*/
export function concatenateSpaceApplyInputs<TOp>(
inputs: readonly SpaceApplyInput<TOp>[],
): readonly SpaceApplyInput<TOp>[] {
const seen = new Set<string>();
for (const input of inputs) {
if (seen.has(input.spaceId)) {
throw errorDuplicateSpaceId(input.spaceId);
}
seen.add(input.spaceId);
}

const extensions: SpaceApplyInput<TOp>[] = [];
let appSpace: SpaceApplyInput<TOp> | undefined;
for (const input of inputs) {
if (input.spaceId === APP_SPACE_ID) {
appSpace = input;
} else {
extensions.push(input);
}
}

extensions.sort((a, b) => {
if (a.spaceId < b.spaceId) return -1;
if (a.spaceId > b.spaceId) return 1;
return 0;
});

return appSpace ? [...extensions, appSpace] : extensions;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Inputs for {@link detectSpaceContractDrift}.
*
* Both hashes are produced by the caller (the SQL-family wiring at the
* consumption site) using the canonical contract hashing pipeline.
* Keeping the helper pure lets `migration-tools` stay framework-neutral
* — the SQL family already speaks `Contract<SqlStorage>`, the Mongo
* family speaks its own contract type, and both reduce to a hash string
* before drift detection runs.
*
* `pinnedHash` is `null` when no pinned `contract.json` exists yet for
* the space (the descriptor declares an extension that has never been
* emitted into the user's repo). That's the "first emit" case — no
* drift to surface; the migrate emit will create the pinned files.
*
* @see specs/framework-mechanism.spec.md § 3 — Drift detection (T1.9).
*/
export interface DetectSpaceContractDriftInputs {
readonly descriptorHash: string;
readonly pinnedHash: string | null;
}

/**
* Result discriminant for {@link detectSpaceContractDrift}.
*
* - `noDrift`: descriptor hash and pinned hash agree byte-for-byte.
* The migrate emit can proceed with no warning.
* - `firstEmit`: no pinned `contract.json` on disk yet. The extension
* was just added to `extensionPacks`; this run will create the
* pinned files. No warning either — the user's intent is to install
* the extension, not to "drift" from a state they haven't pinned.
* - `drift`: descriptor hash differs from pinned hash. The caller
* surfaces a non-fatal warning naming the extension and the
* diff direction (descriptor → pinned). The migrate emit proceeds
* normally so the bump is materialised this run; the warning just
* confirms the bump is being captured.
*
* `spaceId`, `descriptorHash`, and `pinnedHash` are threaded through
* verbatim so the caller (logger / TerminalUI / strict-mode envelope)
* has everything it needs to format the warning message without
* re-reading the descriptor or the pinned file.
*/
export type SpaceContractDriftResult = {
readonly kind: 'noDrift' | 'firstEmit' | 'drift';
readonly spaceId: string;
readonly descriptorHash: string;
readonly pinnedHash: string | null;
};

/**
* Pure drift-detection primitive for a single contract space.
*
* Runs once per loaded extension space, just before computing the
* `priorContract` that feeds {@link import('./plan-all-spaces').planAllSpaces}.
* Hash equality is byte-for-byte (no normalisation) — both sides are
* already canonical hashes produced by the same pipeline, so any
* difference is meaningful drift.
*
* Synchronous, pure, no I/O. The caller (SQL family in M2 R1) reads
* the pinned `contract.json` and computes its hash, then invokes this
* helper alongside the descriptor's `headRef.hash`. Composes naturally
* with {@link import('./read-pinned-contract-hash').readPinnedContractHash}
* which provides the read-side primitive.
*
* @see specs/framework-mechanism.spec.md § 3 — Drift detection (T1.9).
* @see specs/framework-mechanism.spec.md AM7 — drift warning surfaces
* the extension name and the diff direction.
*/
export function detectSpaceContractDrift(
spaceId: string,
inputs: DetectSpaceContractDriftInputs,
): SpaceContractDriftResult {
if (inputs.pinnedHash === null) {
return {
kind: 'firstEmit',
spaceId,
descriptorHash: inputs.descriptorHash,
pinnedHash: null,
};
}
if (inputs.descriptorHash === inputs.pinnedHash) {
return {
kind: 'noDrift',
spaceId,
descriptorHash: inputs.descriptorHash,
pinnedHash: inputs.pinnedHash,
};
}
return {
kind: 'drift',
spaceId,
descriptorHash: inputs.descriptorHash,
pinnedHash: inputs.pinnedHash,
};
}
Loading
Loading