diff --git a/.changeset/core-plugin-type-closed-set.md b/.changeset/core-plugin-type-closed-set.md new file mode 100644 index 0000000000..b0a75604ee --- /dev/null +++ b/.changeset/core-plugin-type-closed-set.md @@ -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. + + diff --git a/content/docs/plugins/anatomy.mdx b/content/docs/plugins/anatomy.mdx index 3a63c34a47..5dd46250d3 100644 --- a/content/docs/plugins/anatomy.mdx +++ b/content/docs/plugins/anatomy.mdx @@ -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) diff --git a/packages/core/src/plugin-type-closed-set.test.ts b/packages/core/src/plugin-type-closed-set.test.ts new file mode 100644 index 0000000000..d998ad50e4 --- /dev/null +++ b/packages/core/src/plugin-type-closed-set.test.ts @@ -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'], + ]); + } + } + }); +}); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bff73a9ee5..7687b550ad 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 @@ -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 * @@ -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. diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 0b22c5f2af..8af4aaf5de 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -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 diff --git a/packages/objectql/src/plugin.integration.test.ts b/packages/objectql/src/plugin.integration.test.ts index a013de8e72..37ced13f10 100644 --- a/packages/objectql/src/plugin.integration.test.ts +++ b/packages/objectql/src/plugin.integration.test.ts @@ -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); @@ -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); @@ -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); diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 32694183ff..9be1d80a28 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -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 diff --git a/packages/plugins/knowledge-memory/src/index.ts b/packages/plugins/knowledge-memory/src/index.ts index d1f2df9a30..4f56deaa4a 100644 --- a/packages/plugins/knowledge-memory/src/index.ts +++ b/packages/plugins/knowledge-memory/src/index.ts @@ -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; diff --git a/packages/plugins/knowledge-ragflow/src/index.ts b/packages/plugins/knowledge-ragflow/src/index.ts index 7825b26a38..4a6f2336f7 100644 --- a/packages/plugins/knowledge-ragflow/src/index.ts +++ b/packages/plugins/knowledge-ragflow/src/index.ts @@ -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; diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index 90837bfc4a..99bc24d17b 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -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; diff --git a/packages/plugins/plugin-approvals/src/plugin-shutdown-cancels-escalation-job.test.ts b/packages/plugins/plugin-approvals/src/plugin-shutdown-cancels-escalation-job.test.ts index 247fbb660d..122735d7f4 100644 --- a/packages/plugins/plugin-approvals/src/plugin-shutdown-cancels-escalation-job.test.ts +++ b/packages/plugins/plugin-approvals/src/plugin-shutdown-cancels-escalation-job.test.ts @@ -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) {} diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index 8a37d64980..170ca0a79a 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -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']; /** diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 8d8137058e..5a6b862273 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -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']; /** diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index 83fc932a7b..e72638e0b7 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -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< diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index d9a384b401..f8042b2767 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -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). diff --git a/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts b/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts index 47d7ef5837..0de0f91b60 100644 --- a/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts +++ b/packages/plugins/plugin-email/src/plugin-shutdown-detaches-template-bridge.test.ts @@ -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(); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 73b5a4e7a7..4cb977a388 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -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) diff --git a/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts b/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts index d19550b56d..3d4bc3ab8d 100644 --- a/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts +++ b/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts @@ -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; diff --git a/packages/plugins/plugin-reports/src/plugin-shutdown-releases-dispatcher.test.ts b/packages/plugins/plugin-reports/src/plugin-shutdown-releases-dispatcher.test.ts index 800473ea17..bc0b61f1b1 100644 --- a/packages/plugins/plugin-reports/src/plugin-shutdown-releases-dispatcher.test.ts +++ b/packages/plugins/plugin-reports/src/plugin-shutdown-releases-dispatcher.test.ts @@ -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) {} diff --git a/packages/plugins/plugin-reports/src/reports-plugin.ts b/packages/plugins/plugin-reports/src/reports-plugin.ts index 581074d2d8..b4cc547a7e 100644 --- a/packages/plugins/plugin-reports/src/reports-plugin.ts +++ b/packages/plugins/plugin-reports/src/reports-plugin.ts @@ -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; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index f5f9c9d9c7..5aef543375 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -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']; diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index a5b33b66e7..1ad3d82f2a 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -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; diff --git a/packages/plugins/plugin-webhooks/src/plugin-shutdown-stops-auto-enqueuer.test.ts b/packages/plugins/plugin-webhooks/src/plugin-shutdown-stops-auto-enqueuer.test.ts index 3de1913e35..a07af1773c 100644 --- a/packages/plugins/plugin-webhooks/src/plugin-shutdown-stops-auto-enqueuer.test.ts +++ b/packages/plugins/plugin-webhooks/src/plugin-shutdown-stops-auto-enqueuer.test.ts @@ -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(); diff --git a/packages/qa/http-conformance/src/node-plugin.ts b/packages/qa/http-conformance/src/node-plugin.ts index d2279efa0f..e0547c855f 100644 --- a/packages/qa/http-conformance/src/node-plugin.ts +++ b/packages/qa/http-conformance/src/node-plugin.ts @@ -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; diff --git a/packages/rest/src/plugin-type-closed-set.pin.test.ts b/packages/rest/src/plugin-type-closed-set.pin.test.ts new file mode 100644 index 0000000000..6ee1e65ec1 --- /dev/null +++ b/packages/rest/src/plugin-type-closed-set.pin.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// COMPILE-TIME pins for the closed `Plugin.type` set on the PUBLISHED surface +// of `@objectstack/core` (#13925): `type?: PluginType`, a union DERIVED from +// the spec's `CORE_PLUGIN_TYPES` (`'standard' | (typeof CORE_PLUGIN_TYPES)[number]`), +// replacing the `type?: string` that let any spelling through while the Zod +// gate (`PluginSchema.type`) refused it at parse. +// +// Why the pins live in THIS package: `@objectstack/core` has no `typecheck` +// script (it is a type-check DEBT ledger entry), so a `@ts-expect-error` there +// is a phantom pin — no tsc program a `typecheck` script runs ever evaluates +// it, and `check:type-check-coverage` refuses it. This package's +// `tsconfig.test.json` program IS run by its `typecheck` script +// (`check:test-typecheck`, EXACT per-file ratchet: an unlisted file must stay +// at zero errors), and it resolves `@objectstack/core` to the BUILT +// `dist/index.d.ts` — so these directives pin the contract consumers actually +// see. Same placement as `plugin-metadata-retired-fields.pin.test.ts`. +// +// Failure channel, proven able to fail by ablation on the narrowing PR: +// reverting `type?: PluginType` to `type?: string` (and rebuilding core's +// dist) turns each directive below into TS2578 "Unused '@ts-expect-error' +// directive", giving this file errors where the ratchet requires 0. +// +// Positive control: every member of the closed set still compiles with no +// directive, and the published union is type-level EQUAL to the spec-derived +// shape — proving the interface still accepts its real members, so the +// directives above are readings, not a broken instrument. The RUNTIME half +// (the Zod enum enumerates the same members) is +// `packages/core/src/plugin-type-closed-set.test.ts`. + +import { describe, it, expect } from 'vitest'; +import type { Plugin, PluginMetadata, PluginType } from '@objectstack/core'; +import type { CORE_PLUGIN_TYPES } from '@objectstack/spec/kernel'; + +/** Strict type equality (no `any` leak, no one-sided assignability). */ +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +describe('Plugin.type closed set — published-surface pins (#13925)', () => { + it('compile-time: a literal outside the set no longer type-checks', () => { + const declared: Plugin = { + name: 'closed-set-pin-literal', + // @ts-expect-error — `'bogus'` is not a `PluginType` (#13925): the + // set is closed at `'standard' | CORE_PLUGIN_TYPES[number]`. + type: 'bogus', + async init() {}, + }; + // The value exists at runtime (TS types are erased); the pin is the + // directive above, enforced by this package's test-typecheck program. + expect(declared.name).toBe('closed-set-pin-literal'); + }); + + it('compile-time: a `string`-typed value no longer type-checks', () => { + // Widened on purpose: `string`, not the literal — the case the old + // `type?: string` admitted and the Zod gate could only catch at parse. + const computed: string = ['u', 'i'].join(''); + const declared: Plugin = { + name: 'closed-set-pin-computed', + // @ts-expect-error — `string` is wider than `PluginType` (#13925); + // narrow at the producer (declare the literal, or type it PluginType). + type: computed, + async init() {}, + }; + expect(declared.name).toBe('closed-set-pin-computed'); + }); + + it('compile-time: the narrowing reaches PluginMetadata (extends Plugin)', () => { + const declared: PluginMetadata = { + name: 'closed-set-pin-metadata', + version: '1.0.0', + // @ts-expect-error — `'ui-plugin'` (a stale describe() spelling) is + // not a `PluginType` (#13925). + type: 'ui-plugin', + async init() {}, + }; + expect(declared.name).toBe('closed-set-pin-metadata'); + }); + + it('positive control: every member still type-checks with no directive, and the union equals the spec-derived shape', () => { + const members = ['standard', 'ui', 'driver', 'server', 'app', 'theme', 'agent', 'objectql'] as const satisfies readonly PluginType[]; + const plugins: Plugin[] = members.map((type) => ({ name: `member-${type}`, type, async init() {} })); + expect(plugins.map((p) => p.type)).toEqual([...members]); + + const parity: Equal = true; + expect(parity).toBe(true); + // Completeness in the other direction: a union member the literal list + // above does not spell would make the Exclude non-never, and `true` + // unassignable to it. + const complete: Equal, never> = true; + expect(complete).toBe(true); + }); +}); diff --git a/packages/runtime/src/app-plugin-shutdown-emits-unregistered.test.ts b/packages/runtime/src/app-plugin-shutdown-emits-unregistered.test.ts index c8aa540ceb..ba43b0b5a0 100644 --- a/packages/runtime/src/app-plugin-shutdown-emits-unregistered.test.ts +++ b/packages/runtime/src/app-plugin-shutdown-emits-unregistered.test.ts @@ -53,7 +53,7 @@ const BUNDLE = { manifest: { id: 'demo_app', name: 'demo_app', label: 'Demo' } } /** Captures the catalog events AppPlugin puts on the kernel bus. */ class CatalogRecorderPlugin implements Plugin { name = 'test.catalog-recorder'; - type = 'standard'; + type = 'standard' as const; version = '1.0.0'; readonly events: string[] = []; init(ctx: PluginContext): void { diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index f7d7d53e3e..ea655dd587 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -87,7 +87,7 @@ export type AppPluginSecurityMetadataRegistrar = 'app-plugin' | 'artifact-door'; */ export class AppPlugin implements Plugin { name: string; - type = 'app'; + type = 'app' as const; version?: string; /** * Ordering — declared, not positional (ADR-0116, the #4131 close of diff --git a/packages/runtime/src/driver-plugin.ts b/packages/runtime/src/driver-plugin.ts index 70ad87bb8b..02e4f221c4 100644 --- a/packages/runtime/src/driver-plugin.ts +++ b/packages/runtime/src/driver-plugin.ts @@ -18,7 +18,7 @@ import { Plugin, PluginContext } from '@objectstack/core'; */ export class DriverPlugin implements Plugin { name: string; - type = 'driver'; + type = 'driver' as const; version = '1.0.0'; private driver: any; diff --git a/packages/runtime/src/external-validation-plugin.ts b/packages/runtime/src/external-validation-plugin.ts index 287e24f984..f888d86feb 100644 --- a/packages/runtime/src/external-validation-plugin.ts +++ b/packages/runtime/src/external-validation-plugin.ts @@ -204,7 +204,7 @@ export interface ExternalSchemaDriftEvent { */ export class ExternalValidationPlugin implements Plugin { name = 'com.objectstack.external-validation'; - type = 'standard'; + type = 'standard' as const; version = '1.0.0'; /** Active background drift-check timers, keyed by datasource name. */ diff --git a/packages/runtime/src/external-validation-shutdown-clears-timers.test.ts b/packages/runtime/src/external-validation-shutdown-clears-timers.test.ts index 2d98b8f317..a01e17aab6 100644 --- a/packages/runtime/src/external-validation-shutdown-clears-timers.test.ts +++ b/packages/runtime/src/external-validation-shutdown-clears-timers.test.ts @@ -55,7 +55,7 @@ const INTERVAL_MS = 1000; /** Counts the drift checker's reads, so "still ticking" is measurable. */ class FakeFederationPlugin implements Plugin { name = 'test.federation'; - type = 'standard'; + type = 'standard' as const; version = '1.0.0'; providesServices = ['external-datasource', 'metadata']; validateAllCalls = 0; diff --git a/packages/runtime/src/observability/observability-service-plugin.ts b/packages/runtime/src/observability/observability-service-plugin.ts index 0849799bbc..56af30f386 100644 --- a/packages/runtime/src/observability/observability-service-plugin.ts +++ b/packages/runtime/src/observability/observability-service-plugin.ts @@ -71,7 +71,7 @@ export interface ObservabilityServicePluginOptions { export class ObservabilityServicePlugin implements Plugin { name = 'com.objectstack.observability.service'; version = '1.0.0'; - type = 'standard'; + type = 'standard' as const; private readonly options: ObservabilityServicePluginOptions; diff --git a/packages/services/service-cache/src/cache-service-plugin.ts b/packages/services/service-cache/src/cache-service-plugin.ts index 8fa8267bc5..81c3ffd3ca 100644 --- a/packages/services/service-cache/src/cache-service-plugin.ts +++ b/packages/services/service-cache/src/cache-service-plugin.ts @@ -67,7 +67,7 @@ export class CacheServicePlugin implements Plugin { */ providesServices = ['cache']; version = '1.0.0'; - type = 'standard'; + type = 'standard' as const; private readonly options: CacheServicePluginOptions; diff --git a/packages/services/service-cluster/src/authz-cluster-bridge-plugin.ts b/packages/services/service-cluster/src/authz-cluster-bridge-plugin.ts index 0d325558e0..b19e10341c 100644 --- a/packages/services/service-cluster/src/authz-cluster-bridge-plugin.ts +++ b/packages/services/service-cluster/src/authz-cluster-bridge-plugin.ts @@ -50,7 +50,7 @@ import { isInProcessClusterDriver } from './split-brain-guard.js'; export class AuthzClusterBridgePlugin implements Plugin { name = 'com.objectstack.service.authz-cluster-bridge'; version = '1.0.0'; - type = 'standard'; + type = 'standard' as const; private detach?: () => void; diff --git a/packages/services/service-cluster/src/cluster-service-plugin.ts b/packages/services/service-cluster/src/cluster-service-plugin.ts index 34ecb12a8f..f1948869ac 100644 --- a/packages/services/service-cluster/src/cluster-service-plugin.ts +++ b/packages/services/service-cluster/src/cluster-service-plugin.ts @@ -47,7 +47,7 @@ export class ClusterServicePlugin implements Plugin { */ providesServices = ['cluster']; version = '1.0.0'; - type = 'standard'; + type = 'standard' as const; private readonly options: ClusterServicePluginOptions; private cluster?: IClusterService; diff --git a/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts b/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts index 62348c3184..643fab836b 100644 --- a/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts +++ b/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts @@ -60,7 +60,7 @@ import { isInProcessClusterDriver } from './split-brain-guard.js'; export class MetadataClusterBridgePlugin implements Plugin { name = 'com.objectstack.service.metadata-cluster-bridge'; version = '1.0.0'; - type = 'standard'; + type = 'standard' as const; private detach?: () => void; private detachMutation?: () => void; diff --git a/packages/services/service-i18n/src/i18n-service-plugin.ts b/packages/services/service-i18n/src/i18n-service-plugin.ts index 9a7976e293..89c1b4568a 100644 --- a/packages/services/service-i18n/src/i18n-service-plugin.ts +++ b/packages/services/service-i18n/src/i18n-service-plugin.ts @@ -72,7 +72,7 @@ export class I18nServicePlugin implements Plugin { */ providesServices = ['i18n']; version = '1.0.0'; - type = 'standard'; + type = 'standard' as const; private readonly options: I18nServicePluginOptions; private i18n: II18nService | null = null; diff --git a/packages/services/service-job/src/job-service-plugin.ts b/packages/services/service-job/src/job-service-plugin.ts index 3c1a5fd16d..025b1d9e55 100644 --- a/packages/services/service-job/src/job-service-plugin.ts +++ b/packages/services/service-job/src/job-service-plugin.ts @@ -69,7 +69,7 @@ export class JobServicePlugin implements Plugin { */ optionalDependencies = ['com.objectstack.engine.objectql', 'com.objectstack.service.cluster']; version = '1.1.0'; - type = 'standard'; + type = 'standard' as const; private readonly options: JobServicePluginOptions; private dbAdapter?: DbJobAdapter; diff --git a/packages/services/service-knowledge/src/__tests__/plugin-shutdown-unsubscribes.test.ts b/packages/services/service-knowledge/src/__tests__/plugin-shutdown-unsubscribes.test.ts index 3b147f2f36..467940f3cf 100644 --- a/packages/services/service-knowledge/src/__tests__/plugin-shutdown-unsubscribes.test.ts +++ b/packages/services/service-knowledge/src/__tests__/plugin-shutdown-unsubscribes.test.ts @@ -42,7 +42,7 @@ interface RealtimeLog { class FakeRealtimePlugin implements Plugin { name = 'test.fake.realtime'; version = '1.0.0'; - type = 'standard'; + type = 'standard' as const; constructor(private readonly log: RealtimeLog) {} diff --git a/packages/services/service-knowledge/src/knowledge-service-plugin.ts b/packages/services/service-knowledge/src/knowledge-service-plugin.ts index 2588e1132b..c07f9d9466 100644 --- a/packages/services/service-knowledge/src/knowledge-service-plugin.ts +++ b/packages/services/service-knowledge/src/knowledge-service-plugin.ts @@ -59,7 +59,7 @@ export interface KnowledgeServicePluginOptions { export class KnowledgeServicePlugin implements Plugin { name = 'com.objectstack.service.knowledge'; version = '0.1.0'; - type = 'standard'; + type = 'standard' as const; /** * init() resolves the `objectql` engine for RLS re-checks — * order-if-present so the resolution is deterministic (ADR-0116, #4471). diff --git a/packages/services/service-queue/src/queue-service-plugin.ts b/packages/services/service-queue/src/queue-service-plugin.ts index 86c742ad68..a387b06d39 100644 --- a/packages/services/service-queue/src/queue-service-plugin.ts +++ b/packages/services/service-queue/src/queue-service-plugin.ts @@ -49,7 +49,7 @@ export class QueueServicePlugin implements Plugin { */ optionalDependencies = ['com.objectstack.engine.objectql']; version = '1.1.0'; - type = 'standard'; + type = 'standard' as const; private readonly options: QueueServicePluginOptions; private dbAdapter?: DbQueueAdapter; diff --git a/packages/services/service-realtime/src/realtime-service-plugin.ts b/packages/services/service-realtime/src/realtime-service-plugin.ts index b9b8040baf..53ceb14c6f 100644 --- a/packages/services/service-realtime/src/realtime-service-plugin.ts +++ b/packages/services/service-realtime/src/realtime-service-plugin.ts @@ -51,7 +51,7 @@ export class RealtimeServicePlugin implements Plugin { */ providesServices = ['realtime']; version = '1.0.0'; - type = 'standard'; + type = 'standard' as const; dependencies = ['com.objectstack.engine.objectql']; private readonly options: RealtimeServicePluginOptions; diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index f79514a3bd..b1d817d3b3 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -165,7 +165,7 @@ export class StorageServicePlugin implements Plugin { 'com.objectstack.service.settings', ]; version = '1.0.0'; - type = 'standard'; + type = 'standard' as const; private readonly options: StorageServicePluginOptions; private storage: SwappableStorageService | null = null; diff --git a/packages/triggers/trigger-api/src/plugin.ts b/packages/triggers/trigger-api/src/plugin.ts index 9fac7b99c4..d9edb649e9 100644 --- a/packages/triggers/trigger-api/src/plugin.ts +++ b/packages/triggers/trigger-api/src/plugin.ts @@ -47,7 +47,7 @@ export const HOOKS_PATH = '/api/v1/automation/hooks/:flowName/:hookId'; */ export class ApiTriggerPlugin implements Plugin { name = 'com.objectstack.trigger.api'; - type = 'standard'; + type = 'standard' as const; version = '1.0.0'; dependencies = ['com.objectstack.service.queue']; diff --git a/packages/triggers/trigger-record-change/src/plugin.ts b/packages/triggers/trigger-record-change/src/plugin.ts index 239721423c..a82ab887f1 100644 --- a/packages/triggers/trigger-record-change/src/plugin.ts +++ b/packages/triggers/trigger-record-change/src/plugin.ts @@ -31,7 +31,7 @@ interface AutomationTriggerRegistry { */ export class RecordChangeTriggerPlugin implements Plugin { name = 'com.objectstack.trigger.record-change'; - type = 'standard'; + type = 'standard' as const; version = '7.3.0'; dependencies = ['com.objectstack.engine.objectql']; diff --git a/packages/triggers/trigger-schedule/src/plugin.ts b/packages/triggers/trigger-schedule/src/plugin.ts index 89cf794c35..ac981eaccc 100644 --- a/packages/triggers/trigger-schedule/src/plugin.ts +++ b/packages/triggers/trigger-schedule/src/plugin.ts @@ -35,7 +35,7 @@ interface AutomationTriggerRegistry { */ export class ScheduleTriggerPlugin implements Plugin { name = 'com.objectstack.trigger.schedule'; - type = 'standard'; + type = 'standard' as const; version = '7.3.0'; dependencies = ['com.objectstack.service.job']; diff --git a/packages/triggers/trigger-schedule/src/time-relative-plugin.ts b/packages/triggers/trigger-schedule/src/time-relative-plugin.ts index 19cc7743e1..8db2a7a5c4 100644 --- a/packages/triggers/trigger-schedule/src/time-relative-plugin.ts +++ b/packages/triggers/trigger-schedule/src/time-relative-plugin.ts @@ -34,7 +34,7 @@ interface AutomationTriggerRegistry { */ export class TimeRelativeTriggerPlugin implements Plugin { name = 'com.objectstack.trigger.time-relative'; - type = 'standard'; + type = 'standard' as const; version = '1.0.0'; dependencies = ['com.objectstack.service.job', 'com.objectstack.engine.objectql'];