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
35 changes: 35 additions & 0 deletions .changeset/core-plugin-type-closed-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@objectstack/core": minor
---

feat(core): `Plugin.type` is the closed set the spec declares — a `PluginType` derived from `CORE_PLUGIN_TYPES` (#13925)

**BREAKING** accept-set narrowing on a published type, shipped as `minor`
under the repo's launch-window convention for breaking changes. `Plugin.type`
(and, through it, `PluginMetadata.type`) was declared `string`, so nothing
type-checked a plugin author against the eight values the platform accepts —
the TSDoc beside it carried the whole enumeration as prose, and prose drifted.
Maintainer ruling 2026-09-01: the Zod enum in `@objectstack/spec`
(`PluginSchema.type`, declared `z.enum(['standard', ...CORE_PLUGIN_TYPES])`)
is the authority and the contract was always a closed set; the `string` in
core was the mismatch, and narrowing it is core aligning to the declared
contract rather than a new restriction. Paid in one stroke — no warning window.

What changes:

- `@objectstack/core` now exports `PluginType`, derived from the spec's own
constant: `'standard' | (typeof CORE_PLUGIN_TYPES)[number]` — today
`standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`.
It is not re-spelled in core, so the compiler's accept set and the Zod gate's
cannot drift apart; a runtime parity test pins the two against each other.
- `Plugin.type` is typed `PluginType`. A literal outside the set, or a value
typed `string`, no longer compiles. Runtime behaviour is unchanged: the Zod
gate refused such a value before and still does (`invalid_value` at `type`).

**Migration.** A plugin that declares one of the eight members needs no change.
A plugin that assigned a computed or `string`-typed value narrows it at the
producer — declare the literal, or type the variable `PluginType` — rather than
casting at the assignment; a value that was never one of the eight was never a
valid plugin type and was already refused at parse time.

<!-- adr-0087: not-required (no-migration-prescription) A TypeScript narrowing on a published runtime interface, aligning `packages/core` to the accept set `packages/spec` already declared. No metadata key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed — `CORE_PLUGIN_TYPES` and `PluginSchema.type` are read, not changed — so `objectstack migrate meta` has nothing to rewrite and there is no tombstone to mint. The channel that reaches an affected author is the compiler, at the assignment, which is more precise than a ledger line; which member a formerly `string`-typed value should become is authoring intent no migration entry can decide. The in-repo census under the workspace typecheck is recorded on the PR. -->
7 changes: 4 additions & 3 deletions content/docs/plugins/anatomy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,12 @@ export interface Plugin {
version?: string;

/**
* Plugin Type (Optional)
* One of: standard, ui, driver, server, app, theme, agent, objectql.
* Plugin Type (Optional) — a `PluginType`, the closed set the spec declares
* (`CORE_PLUGIN_TYPES` plus `standard`): standard, ui, driver, server, app,
* theme, agent, objectql. A value outside it does not compile.
* @default 'standard'
*/
type?: string;
type?: PluginType;

/**
* Dependencies (Optional)
Expand Down
81 changes: 81 additions & 0 deletions packages/core/src/plugin-type-closed-set.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// RUNTIME parity pin for the closed `Plugin.type` set (#13925).
//
// `Plugin.type` in `./types.ts` is a `PluginType` DERIVED from the spec's
// `CORE_PLUGIN_TYPES` constant (`'standard' | (typeof CORE_PLUGIN_TYPES)[number]`),
// and `PluginSchema.type` in `@objectstack/spec` is declared as
// `z.enum(['standard', ...CORE_PLUGIN_TYPES])`. Both sides read the same
// constant, so the one way they can still drift is the Zod enum's literal
// prefix changing shape (a member added to the enum but not to the constant,
// or `'standard'` renamed) — which is exactly what the first case below reads
// off the schema at runtime, member by member and in declared order.
//
// The COMPILE-TIME half — a non-member literal or a `string`-typed value no
// longer type-checks against the PUBLISHED `Plugin.type` — lives in
// `packages/rest/src/plugin-type-closed-set.pin.test.ts`, deliberately NOT
// here: `@objectstack/core` has no `typecheck` script (type-check DEBT ledger
// entry), so a `@ts-expect-error` in this package is a phantom pin no tsc
// program a `typecheck` script runs would ever evaluate —
// `check:type-check-coverage` refuses exactly that. The rest package's
// `tsconfig.test.json` program is compiled by its `typecheck` script and reads
// core's BUILT `.d.ts`, so the pin over there guards the published contract.

import { describe, it, expect } from 'vitest';
import { CORE_PLUGIN_TYPES, PluginSchema } from '@objectstack/spec/kernel';
import type { PluginType } from './types.js';

/**
* The TypeScript union's members, spelled by the same derivation `PluginType`
* uses. `satisfies` makes each entry a member of the union; the schema
* comparison below makes the list COMPLETE against the Zod enum.
*/
const UNION_MEMBERS = ['standard', ...CORE_PLUGIN_TYPES] as const satisfies readonly PluginType[];

/**
* Walks the wrapper chain `PluginSchema.shape.type` carries
* (`optional` → `default` → `enum`, measured at 9c7d9d4b3) down to the enum's
* declared options. Throws rather than returning `[]` when no enum is found,
* so a re-shaped key cannot read as "zero members, all equal".
*/
function zodEnumOptions(schema: unknown): readonly string[] {
let node = schema as { options?: readonly string[]; def?: { innerType?: unknown } } | undefined;
while (node) {
if (Array.isArray(node.options)) return node.options;
node = node.def?.innerType as typeof node;
}
throw new Error('PluginSchema.shape.type carries no z.enum in its wrapper chain');
}

describe('Plugin.type closed set — runtime parity with the spec enum (#13925)', () => {
it('the Zod enum enumerates exactly the TypeScript union, in declared order', () => {
const options = zodEnumOptions(PluginSchema.shape.type);
expect(options).toEqual([...UNION_MEMBERS]);
// Positive control on the instrument: the list is populated and the
// spec constant is the seven-member set the union is derived from.
expect(options).toHaveLength(8);
expect(CORE_PLUGIN_TYPES).toHaveLength(7);
});

it('every union member parses through PluginSchema', () => {
for (const type of UNION_MEMBERS) {
const result = PluginSchema.safeParse({ type });
expect(result.success, `PluginSchema refused union member '${type}'`).toBe(true);
}
});

it('a non-member is refused by PluginSchema with invalid_value at ["type"]', () => {
// `'plugin'` / `'module'` are PACKAGE manifest types (ManifestSchema.type),
// never plugin types; `'ui-plugin'` is the spelling a stale describe()
// string still uses; the casing variant guards against a lax comparator.
for (const type of ['bogus', 'ui-plugin', 'plugin', 'module', 'Standard']) {
const result = PluginSchema.safeParse({ type });
expect(result.success, `PluginSchema accepted non-member '${type}'`).toBe(false);
if (!result.success) {
expect(result.error.issues.map((i) => [i.code, i.path.join('.')])).toEqual([
['invalid_value', 'type'],
]);
}
}
});
});
26 changes: 18 additions & 8 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { ObjectKernel } from './kernel.js';
import type { Logger, LifecycleEventName } from '@objectstack/spec/contracts';
import type { CORE_PLUGIN_TYPES } from '@objectstack/spec/kernel';

/**
* PluginContext - Runtime context available to plugins
Expand Down Expand Up @@ -91,6 +92,18 @@ export interface PluginContext {
getKernel(): ObjectKernel;
}

/**
* The closed set of plugin types (#13925): `'standard'` plus the seven
* `CORE_PLUGIN_TYPES` members, in exactly the shape `PluginSchema.type`
* declares in `@objectstack/spec` (`kernel/plugin.zod.ts`:
* `z.enum(['standard', ...CORE_PLUGIN_TYPES])`). Derived from the spec's own
* constant rather than re-spelled here, so the compiler's accept set and the
* Zod gate's cannot drift apart: `plugin-type-closed-set.test.ts` pins the
* parity at runtime, and `packages/rest`'s `plugin-type-closed-set.pin.test.ts`
* pins the published `.d.ts` at compile time.
*/
export type PluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number];

/**
* Plugin Interface
*
Expand All @@ -108,16 +121,13 @@ export interface Plugin {
version?: string;

/**
* Plugin type (standard, ui, driver, server, app, theme, agent, objectql)
*
* Authoritative set: `CORE_PLUGIN_TYPES` in `@objectstack/spec`
* (`kernel/plugin.zod.ts`), which `PluginSchema.type` enumerates as
* `z.enum(['standard', ...CORE_PLUGIN_TYPES])`. This field is typed
* `string`, so nothing type-checks an author against the list above —
* keep the two in step when the declared set changes.
* Plugin type categorisation for runtime behaviour — a {@link PluginType},
* the closed set the spec declares. The enumeration lives on that type
* (derived from `CORE_PLUGIN_TYPES`), not in this comment: a value outside
* it no longer type-checks, and `PluginSchema.type` refuses it at parse.
* @default 'standard'
*/
type?: string;
type?: PluginType;

/**
* List of other plugin names that this plugin depends on.
Expand Down
2 changes: 1 addition & 1 deletion packages/metadata/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export interface MetadataPluginOptions {

export class MetadataPlugin implements Plugin {
name = 'com.objectstack.metadata';
type = 'standard';
type = 'standard' as const;
version = '1.0.0';
/**
* Services init() UNCONDITIONALLY registers (ADR-0116, #4131) — lets the
Expand Down
6 changes: 3 additions & 3 deletions packages/objectql/src/plugin.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {

await kernel.use({
name: 'mock-metadata',
type: 'test',
type: 'standard',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('metadata', mockMetadataService);
Expand Down Expand Up @@ -320,7 +320,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
// Register mock metadata service BEFORE ObjectQL
await kernel.use({
name: 'mock-metadata',
type: 'metadata',
type: 'standard',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('metadata', mockMetadataService);
Expand Down Expand Up @@ -368,7 +368,7 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {

await kernel.use({
name: 'mock-metadata',
type: 'metadata',
type: 'standard',
version: '1.0.0',
init: async (ctx) => {
ctx.registerService('metadata', mockMetadataService);
Expand Down
2 changes: 1 addition & 1 deletion packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export interface ObjectQLPluginOptions {

export class ObjectQLPlugin implements Plugin {
name = 'com.objectstack.engine.objectql';
type = 'objectql';
type = 'objectql' as const;
version = '1.0.0';
/**
* Services init() UNCONDITIONALLY registers (ADR-0116, #4131) — lets the
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/knowledge-memory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export interface KnowledgeMemoryPluginOptions {
export class KnowledgeMemoryPlugin implements Plugin {
name = 'com.objectstack.plugin.knowledge-memory';
version = '0.1.0';
type = 'standard';
type = 'standard' as const;

private readonly adapter: KnowledgeMemoryAdapter;

Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/knowledge-ragflow/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export interface KnowledgeRagflowPluginOptions extends KnowledgeRagflowAdapterOp
export class KnowledgeRagflowPlugin implements Plugin {
name = 'com.objectstack.plugin.knowledge-ragflow';
version = '0.1.0';
type = 'standard';
type = 'standard' as const;

private readonly adapter: KnowledgeRagflowAdapter;

Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-approvals/src/approvals-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export interface ApprovalsPluginOptions {
export class ApprovalsServicePlugin implements Plugin {
name = 'com.objectstack.service.approvals';
version = '1.0.0';
type = 'standard';
type = 'standard' as const;
dependencies = ['com.objectstack.engine.objectql'];

private readonly options: ApprovalsPluginOptions;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ interface JobLog {
class FakeJobServicePlugin implements Plugin {
name = 'test.fake.job';
version = '1.0.0';
type = 'standard';
type = 'standard' as const;

constructor(private readonly log: JobLog) {}

Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-audit/src/audit-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export interface AuditPluginOptions {
*/
export class AuditPlugin implements Plugin {
name = 'com.objectstack.audit';
type = 'standard';
type = 'standard' as const;
version = '1.0.0';
dependencies = ['com.objectstack.engine.objectql'];
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ export class AuthPlugin implements Plugin {
* kernel name this plugin when a consumer requires one before it inits.
*/
providesServices = ['auth', 'tenancy'];
type = 'standard';
type = 'standard' as const;
version = '1.0.0';
dependencies: string[] = ['com.objectstack.engine.objectql'];
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-dev/src/dev-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ function reportOptionalLoadFailure(ctx: PluginContext, err: unknown, spec: Optio
*/
export class DevPlugin implements Plugin {
name = 'com.objectstack.plugin.dev';
type = 'standard';
type = 'standard' as const;
version = '1.0.0';

private options: Required<
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-email/src/email-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ export class EmailServicePlugin implements Plugin {
*/
providesServices = ['email'];
version = '1.0.0';
type = 'standard';
type = 'standard' as const;
dependencies = ['com.objectstack.engine.objectql'];
/**
* Order-if-present on the settings service (ADR-0116, #10250).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ const template = () => ({
/** Registers the collaborators the email plugin resolves. Nothing under test. */
class FixturePlugin implements Plugin {
name = 'com.objectstack.engine.objectql';
type = 'standard';
type = 'standard' as const;
version = '1.0.0';
providesServices = ['objectql', 'manifest', 'metadata', 'protocol'];
readonly engine = fakeEngine();
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export class HonoServerPlugin implements Plugin {
* kernel name this plugin when a consumer requires one before it inits.
*/
providesServices = ['http.server', 'http-server'];
type = 'server';
type = 'server' as const;
version = '0.9.0';

// No endpoint-priority constants: three of them (DEFAULT/CORE/DISCOVERY)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export interface PinyinSearchPluginOptions {
export class PinyinSearchPlugin implements Plugin {
name = 'com.objectstack.plugin.pinyin-search';
version = '1.0.0';
type = 'standard';
type = 'standard' as const;
dependencies = ['com.objectstack.engine.objectql'];

private readonly options: PinyinSearchPluginOptions;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ interface JobLog {
class FakeJobServicePlugin implements Plugin {
name = 'test.fake.job';
version = '1.0.0';
type = 'standard';
type = 'standard' as const;

constructor(private readonly log: JobLog) {}

Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-reports/src/reports-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface ReportsPluginOptions {
export class ReportsServicePlugin implements Plugin {
name = 'com.objectstack.service.reports';
version = '1.0.0';
type = 'standard';
type = 'standard' as const;
dependencies = ['com.objectstack.engine.objectql'];

private readonly options: ReportsPluginOptions;
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-security/src/security-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,7 +753,7 @@ export class SecurityPlugin implements Plugin {
* kernel name this plugin when a consumer requires one before it inits.
*/
providesServices = ['security.permissions', 'security.rls', 'security.fieldMasker', 'security.bootstrapPermissionSets', 'security.fallbackPermissionSet', 'security.baselinePermissionSets'];
type = 'standard';
type = 'standard' as const;
version = '1.0.0';
dependencies = ['com.objectstack.engine.objectql'];

Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/plugin-sharing/src/sharing-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ export async function backfillRetiredAccessLevels(
export class SharingServicePlugin implements Plugin {
name = 'com.objectstack.service.sharing';
version = '1.0.0';
type = 'standard';
type = 'standard' as const;
dependencies = ['com.objectstack.engine.objectql'];

private readonly options: SharingPluginOptions;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function fakeEngine() {
*/
class FixturePlugin implements Plugin {
name = 'com.objectstack.service.messaging';
type = 'standard';
type = 'standard' as const;
version = '1.0.0';
providesServices = ['manifest', 'objectql', 'realtime', 'messaging'];
readonly realtime = fakeRealtime();
Expand Down
2 changes: 1 addition & 1 deletion packages/qa/http-conformance/src/node-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface NodeServerPluginOptions {
*/
export class NodeServerPlugin implements Plugin {
name = 'com.objectstack.server.node';
type = 'server';
type = 'server' as const;
version = '0.1.0';

private server: NodeHttpServer;
Expand Down
Loading
Loading