Skip to content
44 changes: 44 additions & 0 deletions .changeset/deletepackage-shared-seam-type.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
"@objectstack/metadata-protocol": patch
"@objectstack/rest": patch
"@objectstack/runtime": patch
---

refactor(metadata-protocol,rest,runtime): one declared shape for the `protocol.deletePackage` seam, imported by both doors (#9960)

`deletePackage` had **three** independent statements of its own contract, and
they did not agree:

| site | what it said |
|---|---|
| `packages/metadata-protocol/src/protocol.ts` (the producer) | an inline structural type on the method — `packageId`, `organizationId?`, `allTenants?`, `actor?`, `keepData?` |
| `packages/rest/src/package-routes.ts` (direct-mount option) | `{ packageId; actor?; allTenants? }` — named **neither** `organizationId` **nor** `keepData`, and its response omitted `deleted` |
| `packages/runtime/src/domains/packages.ts` (dispatcher twin) | nothing at all — it reached the verb through `(protocol as any)` |

The twin routinely sent exactly the two keys the REST option's type could not
express, and the only reason that was not a compile error was the cast.

`organizationId` is the member that makes this load-bearing rather than
cosmetic: the protocol refuses a call naming neither it nor `allTenants`
(`TENANT_SCOPE_REQUIRED`, 400), so it is precisely the key whose presence
decides an uninstall's blast radius — and it was the key one of the two doors
had no word for.

**What changes:** `DeletePackageRequest` and `DeletePackageResponse` are
declared once at the producer and exported from `@objectstack/metadata-protocol`
(the only user-visible half of this change — two additive type exports); both
consumers import them, and the `as any` seam is gone. `@objectstack/rest` also
gains three compile-time pins over its option, in compiled source rather than a
test file, so a later hand-rolled restatement fails `tsc` instead of drifting
green.

**What does not change:** nothing about what the verb accepts or returns. The
members are identical to the ones the implementation already had, the live call
sites send the same keys, and the emitted JavaScript of both consumers is
unchanged. The member stays optional at both seams and the runtime's
`typeof … === 'function'` capability probe stays — the `protocol` service slot
is deliberately uncontracted, the spec's `PackageProtocol` does not declare this
verb, and registrants carrying no `deletePackage` are real.

No `packages/spec` declaration: minting protocol surface for a verb with zero
external consumers is a spec-seat decision nobody has asked for.
5 changes: 5 additions & 0 deletions packages/metadata-protocol/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -104,6 +104,11 @@ export type {
SeedTenancyCollision,
} from './migrations/seed-tenancy-backfill.js';
export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js';
// [#9960] The ONE declared shape of the `deletePackage` seam, exported so the
// two consumers that speak it (`@objectstack/rest`'s direct-mount package
// registrar and the `@objectstack/runtime` dispatcher twin) type the seam
// against the producer's contract instead of restating it locally.
export type { DeletePackageRequest, DeletePackageResponse } from './protocol.js';
export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js';
export type { MetadataAuthoringGate, MetadataAuthoringGateContext } from './protocol.js';

Expand Down
76 changes: 62 additions & 14 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3224,6 +3224,67 @@ export interface UninstallCleanupOutcome {
error?: string;
}

/**
* [#9960] The declared REQUEST shape of
* {@link ObjectStackProtocolImplementation.deletePackage} — the ONE statement
* of this verb's contract, imported by every seam that speaks it.
*
* WHY IT IS NAMED. `deletePackage` has no `packages/spec` declaration (it is
* absent from `PackageProtocol`), and until this type existed its shape was
* stated THREE times, differently, by the three modules that share the seam:
* an inline structural type on the method below; a narrower restatement on
* `PackageRoutesOptions.protocol.deletePackage` in `@objectstack/rest`, which
* named neither `organizationId` nor `keepData`; and no type at all in the
* dispatcher twin (`packages/runtime/src/domains/packages.ts`), which reached
* the verb through `(protocol as any)` and routinely sent exactly the two keys
* the REST restatement could not express. Naming the shape once, HERE at the
* producer, is what makes the other two seams compile-checked against the
* contract instead of each against its own copy.
*
* ⛔ Deliberately NOT declared in `packages/spec`: minting protocol surface for
* a verb with zero external consumers is declare-and-maintain the platform has
* not asked for. Should an external consumer ever appear, the spec declaration
* is its own card for the spec seat, not a rider on a typing convergence.
*
* `organizationId` is the load-bearing member. The tenant-scope gate in
* `deletePackage` refuses a call naming neither it nor `allTenants`
* (`TENANT_SCOPE_REQUIRED`, 400 — #7780), so it is precisely the key whose
* presence decides an uninstall's blast radius, and it was the key the REST
* seam's own type had no word for.
*/
export interface DeletePackageRequest {
packageId: string;
/**
* Scope the uninstall to ONE organization's rows (#7705). Omitted together
* with `allTenants` ⇒ refused, never inferred as "every tenant" (#7780).
*/
organizationId?: string;
/** DECLARE a cross-tenant uninstall. Never deduced from an absent org (#7780). */
allTenants?: boolean;
actor?: string;
/** Remove the metadata but PRESERVE each object's physical table. */
keepData?: boolean;
}

/**
* [#9960] The declared RESPONSE shape of
* {@link ObjectStackProtocolImplementation.deletePackage}, stated once for the
* same reason as {@link DeletePackageRequest}.
*
* `deleted` is part of it. The REST seam's restatement omitted that member
* entirely, so a caller reading the option's type was told this verb reports
* only a COUNT of what it removed — while the producer has always returned the
* per-item list beside it.
*/
export interface DeletePackageResponse {
success: boolean;
deletedCount: number;
failedCount: number;
deleted: Array<{ type: string; name: string; state: string }>;
failed: Array<{ type: string; name: string; error: string; code?: string }>;
cleanups: UninstallCleanupOutcome[];
}

/**
* Post-persistence metadata-mutation notification (#2588). Emitted by
* `saveMetaItem` / `publishMetaItem` / `deleteMetaItem` AFTER the write
Expand DownExpand Up@@ -15376,20 +15437,7 @@ export class ObjectStackProtocolImplementation implements
* so each object's table is torn down once. Per-item failures are collected
* without aborting the rest.
*/
async deletePackage(request: {
packageId: string;
organizationId?: string;
allTenants?: boolean;
actor?: string;
keepData?: boolean;
}): Promise<{
success: boolean;
deletedCount: number;
failedCount: number;
deleted: Array<{ type: string; name: string; state: string }>;
failed: Array<{ type: string; name: string; error: string; code?: string }>;
cleanups: UninstallCleanupOutcome[];
}> {
async deletePackage(request: DeletePackageRequest): Promise<DeletePackageResponse> {
// [#7780] A cross-tenant uninstall must be DECLARED, never inferred from
// an absent parameter. Maintainer ruling (2026-08-12):
// 跨租户卸载必须显式声明,缺省缺参永远不等于「全部租户」.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,10 @@ function harness(options: { protocol?: boolean } = {}) {
protocol: {
deletePackage: async () => {
spy.protocolCalls += 1;
return { success: true, deletedCount: 3, failedCount: 0, failed: [], cleanups: [] };
// [#9960] `deleted` is part of the verb's declared response —
// the option's own type says so now, so a double that omits it no
// longer compiles. Empty here: these cases count CALLS, not rows.
return { success: true, deletedCount: 3, failedCount: 0, deleted: [], failed: [], cleanups: [] };
},
},
}
Expand Down
82 changes: 72 additions & 10 deletions packages/rest/src/package-routes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,6 +29,18 @@ import { readSingleQueryValue, repeatedQueryParamMessage } from './query-multipl
// while this file keeps compiling green. Same discipline as the sibling
// meta-read doors in `rest-server.ts` (#9805 / #9741).
import type { GetMetaItemsRequest, GetMetaItemsResponse } from '@objectstack/spec/api';
// [#9960] The declared uninstall shapes for the `protocol.deletePackage` seam
// below, imported from the PRODUCER for the same reason the meta-read shapes
// above come from the spec: so this module's idea of the request/response is
// the one statement of that contract rather than a local restatement the
// producer can drift away from while this file keeps compiling green. There is
// no spec shape to import for this verb — `deletePackage` is deliberately
// undeclared in `packages/spec` (zero external consumers, #9960) — so the
// producer's own exported type IS the contract. Type-only: no runtime import of
// `@objectstack/metadata-protocol` exists here, and this package still does not
// depend on it at run time (see `query-multiplicity.ts` and `rest-server.ts`,
// which duck-type the same seam for exactly that reason).
import type { DeletePackageRequest, DeletePackageResponse } from '@objectstack/metadata-protocol';

/**
* [#7033 / #7023] The authorization gate for the REST package transport.
Expand DownExpand Up@@ -250,16 +262,25 @@ export interface PackageRoutesOptions {
* tolerates — a behaviour question, deliberately not answered here.
*/
getMetaItems?(req: GetMetaItemsRequest): Promise<GetMetaItemsResponse>;
// [#7780] `allTenants` is the explicit carrier for cross-tenant uninstall
// semantics; the protocol refuses a call that names neither it nor an
// `organizationId` (`TENANT_SCOPE_REQUIRED`, 400).
deletePackage?(req: { packageId: string; actor?: string; allTenants?: boolean }): Promise<{
success: boolean;
deletedCount: number;
failedCount: number;
failed: Array<{ type: string; name: string; error: string; code?: string }>;
cleanups: Array<{ name: string; success: boolean; removed: number; error?: string }>;
}>;
/**
* [#7780] `allTenants` is the explicit carrier for cross-tenant uninstall
* semantics; the protocol refuses a call that names neither it nor an
* `organizationId` (`TENANT_SCOPE_REQUIRED`, 400).
*
* [#9960] Request/response are the PRODUCER's declared shapes. The local
* restatement they replace named neither `organizationId` nor `keepData`
* and omitted `deleted` from the response — so the one key that decides an
* uninstall's blast radius had no word for it here, while the dispatcher
* twin sent that key on every org-scoped call. The member stays OPTIONAL
* and the call site below keeps its `typeof … === 'function'`
* feature-detection: the `protocol` service slot is deliberately
* uncontracted (`ServiceSlotContracts`), the spec's own `PackageProtocol`
* does not declare this verb at all, and registrants that carry no
* `deletePackage` are real — so requiring the member here would change what
* this seam tolerates, which is a behaviour question this card does not
* answer.
*/
deletePackage?(req: DeletePackageRequest): Promise<DeletePackageResponse>;
};
/**
* [#7033 / #7023] Resolve the caller's execution context for a package route
Expand DownExpand Up@@ -329,6 +350,47 @@ export type _PinGetMetaItemsStaysOptional = Pinned<
undefined extends NonNullable<PackageRoutesOptions['protocol']>['getMetaItems'] ? true : false
>;

/**
* [#9960] The same three pins for the `protocol.deletePackage` seam, and for
* the same reason — with one difference worth stating: `getMetaItems` above is
* pinned to the SPEC's declared shapes, while this verb has no spec
* declaration, so the producer (`@objectstack/metadata-protocol`) is the
* contract these pin against. That is the adjudicated shape of #9960, not an
* oversight: declaring a protocol verb for a surface with zero external
* consumers is a spec-seat decision nobody has asked for.
*
* WHAT THEY CATCH: that this option's request/response are still the producer's
* types rather than a hand-rolled restatement of them. Exact equality, not
* mutual assignability — the shape this card removed (`{ packageId; actor?;
* allTenants? }`) is assignable to `DeletePackageRequest` in one direction, so
* an assignability check would have passed on the very divergence that made
* `organizationId` and `keepData` unsayable here.
*
* They live in compiled source, not a `*.test.ts`, for the reason spelled out
* above the `getMetaItems` pins: this package's `tsconfig.json` excludes its
* test files, so a type-level assertion written there is compiled by nothing.
*/
type DeclaredDeletePackage = NonNullable<NonNullable<PackageRoutesOptions['protocol']>['deletePackage']>;

/** The REQUEST type is exactly the producer's `DeletePackageRequest`. */
export type _PinDeletePackageRequestIsProducerDeclared = Pinned<
ExactlyEqual<Parameters<DeclaredDeletePackage>[0], DeletePackageRequest>
>;

/** The RESPONSE type is exactly the producer's `DeletePackageResponse`. */
export type _PinDeletePackageResponseIsProducerDeclared = Pinned<
ExactlyEqual<Awaited<ReturnType<DeclaredDeletePackage>>, DeletePackageResponse>
>;

/**
* The member stays OPTIONAL — see the option's own note. This pin fails if a
* later edit quietly makes it required, which would turn a protocol registrant
* without the verb from a supported shape into a type error.
*/
export type _PinDeletePackageStaysOptional = Pinned<
undefined extends NonNullable<PackageRoutesOptions['protocol']>['deletePackage'] ? true : false
>;

/**
* Register package management API routes
*
Expand Down
49 changes: 47 additions & 2 deletions packages/rest/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,8 +2,53 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"types": ["node"]
// [#9960] Widened from `./src` as a CONSEQUENCE of the `paths` rule below,
// not as a preference. Redirecting the producer's specifier to its source
// puts `packages/metadata-protocol/src/**` into this program, and `rootDir`
// is enforced over every program file even under `--noEmit`: measured here
// at 20 x `TS6059: File '.../packages/metadata-protocol/src/...' is not
// under 'rootDir'`, and with `rootDir` DELETED tsc infers one from this
// package's own inputs and reports the identical 20. `..` is the directory
// that genuinely contains every file in the program, which is what the
// diagnostic asks for. Emit is unaffected in everything that ships: this
// package builds with tsup (`tsup --config ../../tsup.config.ts`), which
// takes entry and out dir from that config, and `typecheck` passes
// `--noEmit`. The one script that emits through tsc is `dev` (`tsc -w`).
"rootDir": "..",
"types": ["node"],
// [#9960] `@objectstack/metadata-protocol` is type-imported by
// `src/package-routes.ts` (the declared `deletePackage` request/response
// shapes). Without this rule tsc resolves that specifier through the
// dependency's `exports` map — i.e. `dist/index.d.ts`, A BUILD ARTIFACT —
// so this package's `typecheck` would render a verdict about the last
// `pnpm build` rather than about the producer's source in the checkout.
// `check:type-source-resolution` refuses exactly that, and its header
// states why the dangerous case is a typecheck that PASSES.
//
// ONE rule, not two, and that is the predicate rather than a shortcut:
// `@objectstack/metadata-protocol` publishes a single entry point (its
// `exports` map has only `"."`), so there is no namespace subpath for a
// `@objectstack/metadata-protocol/*` rule to redirect. The gate judges
// rules INDIVIDUALLY against the specifiers actually imported and does not
// demand a block for imports that do not exist — a second rule here would
// point at files that are not on disk, which is worse than absent: a
// `paths` target that does not exist makes tsc fall back to node
// resolution, i.e. to `dist`, silently.
//
// ⛔ Never spell the key `@objectstack/metadata-protocol*` (star NOT
// preceded by a slash). That matches the bare name AND every subpath and
// folds them all onto one target; because the index re-exports most of the
// surface it does not crash, it type-checks against the wrong module and
// stays green. See `packages/qa/downstream-contract/tsconfig.json`, the
// repo's other `paths` block, for the measured version of that trap.
//
// No `lib` / `types` mirroring was needed, unlike that block: both packages
// extend the same root config and both already declare `types: ["node"]`,
// so the producer's source compiles under this package's environment with
// zero errors (measured: `tsc --noEmit` exit 0 over the merged program).
"paths": {
"@objectstack/metadata-protocol": ["../metadata-protocol/src/index.ts"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"]
Expand Down
Loading
Loading