diff --git a/.changeset/converge-activation-event-schema.md b/.changeset/converge-activation-event-schema.md new file mode 100644 index 0000000000..836c0b6989 --- /dev/null +++ b/.changeset/converge-activation-event-schema.md @@ -0,0 +1,77 @@ +--- +"@objectstack/spec": major +--- + +feat(spec)!: 双源 C5 收敛 — `ActivationEventSchema` 归 `./kernel` 结构化形状,`./studio` re-export (#4653) + +`ActivationEventSchema` 这个名字过去在两个入口解析到**两份不同的声明**,插件作者拿到哪套校验取决于他从哪个子路径 import(#4411 陷阱): + +| 入口 | 声明 | 作者写的样子 | +|:--|:--|:--| +| `@objectstack/spec/kernel` | `z.object({ type: z.enum([...]), pattern: z.string() })` | `{ type: 'onCommand', pattern: 'my.cmd' }` | +| `@objectstack/spec/studio` | `z.string()` | `'onCommand:my.cmd'` | + +两侧都在作者面上(kernel 侧嵌在 `DynamicLoadRequest.activationEvents`,studio 侧嵌在 `StudioPluginManifest.activationEvents`,后者正是 `defineStudioPlugin` 的入参),所以没有"死侧"可删。v17 统一到**结构化形状**:`./studio` 现在 re-export `./kernel` 的那一份声明,平台只剩一套激活词表。 + +**为什么是结构化的那一侧赢。** 字符串那一侧更眼熟(照搬 VS Code),但它什么都不校验:`z.string()` 接受 `''`、`'banana'`,以及真正要命的 `'onMetadatType:flow'` —— 这个文件文档里列的词表(`*`、`onMetadataType:`、`onCommand:`、`onView:`)只活在散文里,拼错永远静默通过。结构化形状用 enum 在**创作时**就把触发器类型钉死,这才是声明它的意义。 + +## FROM → TO + +`activationEvents` 的每一项从字符串变成对象。冒号前的段成为 `type`,冒号后的段成为 `pattern`: + +```ts +// FROM (v16 及以前,@objectstack/spec/studio) +defineStudioPlugin({ + id: 'objectstack.flow-designer', + name: 'Flow Designer', + activationEvents: ['onMetadataType:flow'], +}); + +// TO (v17+) +defineStudioPlugin({ + id: 'objectstack.flow-designer', + name: 'Flow Designer', + activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }], +}); +``` + +逐条对照: + +| FROM | TO | +|:--|:--| +| `'*'` | `{ type: 'onStartup', pattern: '*' }` | +| `'onMetadataType:flow'` | `{ type: 'onMetadataType', pattern: 'flow' }` | +| `'onCommand:myPlugin.doSomething'` | `{ type: 'onCommand', pattern: 'myPlugin.doSomething' }` | +| `'onView:myPlugin.myPanel'` | `{ type: 'onView', pattern: 'myPlugin.myPanel' }` | + +`StudioPluginManifest.activationEvents` 的默认值随之从 `['*']` 变为 `[{ type: 'onStartup', pattern: '*' }]`。`'*'` 没有拿到独立的 `type`:它一直就是"立即激活",而 kernel 侧的 `onStartup` 本来就是这个意思,再加一个枚举值只会造出两个同义词。 + +## 词表 = 两侧并集,没有能力被静默拿掉 + +enum 取**两侧 v17 前词表的并集**,共 9 个值: + +| 值 | 来源 | +|:--|:--| +| `onCommand` | kernel enum + studio 文档 `onCommand:myPlugin.doSomething` | +| `onRoute` | kernel enum | +| `onObject` | kernel enum | +| `onEvent` | kernel enum | +| `onService` | kernel enum | +| `onSchedule` | kernel enum | +| `onStartup` | kernel enum;同时是 studio `'*'` 的落点 | +| `onMetadataType` | studio 文档/测试 `onMetadataType:object` —— kernel 原本没有 | +| `onView` | studio 文档/测试 `onView:myPlugin.myPanel` —— kernel 原本没有 | + +**未采纳**:cloud-v1 未发布的 marketplace runtime 里的 `priority`、`onInstall`、`onWebhook`。四仓无人读它们,而新增一个 declared-but-unenforced 的键正是 ADR-0049 在清的债 —— 等真有执行点再单独提。 + +## 迁移是手工的,但失败是响亮的 + +**没有随附 ADR-0087 conversion,因为写不出能跑到的那一个。** conversion 层(`applyConversions`)接在 `normalizeStackInput` 上,只走 stack 树;而 `StudioPluginManifestSchema` 和 `DynamicLoadRequestSchema` 都是**根 schema**,没有任何父 schema 嵌入它们(前者由 `defineStudioPlugin` 直接 parse,后者是运行时请求载荷),都不在 stack 里。伪造一个永远不会命中的 conversion 只会制造"已自动迁移"的假象。 + +手工迁移步骤:按上表把每个字符串改写成 `{ type, pattern }`。**漏改会在 parse 处响亮失败** —— `StudioPluginManifestSchema` 是 `strictObject`,字符串遇到对象 schema 直接抛错,不存在静默吞掉或强制转换。 + +## 其它影响 + +- `@objectstack/spec/studio` 现在**额外导出** `ActivationEvent` 类型(此前只有 schema),与 `./kernel` 指向同一份声明。 +- `ActivationEventSchema` 从 `dual-source-exports.baseline.json` 移除,基线 22 → 21。 +- 零可作者化 key 消失、零 tombstone:kernel 的 `ActivationEvent:type` / `:pattern` 原样存活,`studio/ActivationEvent` 侧新增 2 个 key(字符串没有 key,对象有),属 `gen:schema` 允许的**新增**。 diff --git a/content/docs/plugins/development.mdx b/content/docs/plugins/development.mdx index 39b230af9c..e40621b6b4 100644 --- a/content/docs/plugins/development.mdx +++ b/content/docs/plugins/development.mdx @@ -384,7 +384,7 @@ export const manifest = defineStudioPlugin({ name: 'Flow Designer', version: '2.0.0', description: 'Visual flow builder for automation workflows', - activationEvents: ['onMetadataType:flow'], + activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }], contributes: { metadataViewers: [{ diff --git a/content/docs/references/kernel/plugin-runtime.mdx b/content/docs/references/kernel/plugin-runtime.mdx index 9a2786fb64..cdfdad9e4d 100644 --- a/content/docs/references/kernel/plugin-runtime.mdx +++ b/content/docs/references/kernel/plugin-runtime.mdx @@ -38,13 +38,27 @@ This protocol enables: ## TypeScript Usage ```typescript -import { DynamicLoadRequestSchema, DynamicPluginOperationSchema, DynamicPluginResultSchema, DynamicUnloadRequestSchema, PluginSourceSchema } from '@objectstack/spec/kernel'; -import type { DynamicLoadRequest, DynamicPluginOperation, DynamicPluginResult, DynamicUnloadRequest, PluginSource } from '@objectstack/spec/kernel'; +import { ActivationEventSchema, DynamicLoadRequestSchema, DynamicPluginOperationSchema, DynamicPluginResultSchema, DynamicUnloadRequestSchema, PluginSourceSchema } from '@objectstack/spec/kernel'; +import type { ActivationEvent, DynamicLoadRequest, DynamicPluginOperation, DynamicPluginResult, DynamicUnloadRequest, PluginSource } from '@objectstack/spec/kernel'; // Validate data -const result = DynamicLoadRequestSchema.parse(data); +const result = ActivationEventSchema.parse(data); ``` +--- + +## ActivationEvent + +Lazy activation trigger for a dynamic plugin + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `Enum<'onCommand' \| 'onRoute' \| 'onObject' \| 'onEvent' \| 'onService' \| 'onSchedule' \| 'onStartup' \| 'onMetadataType' \| 'onView'>` | ✅ | Trigger type for lazy activation | +| **pattern** | `string` | ✅ | Match pattern for the activation trigger | + + --- ## DynamicLoadRequest @@ -57,7 +71,7 @@ Request to dynamically load a plugin at runtime | :--- | :--- | :--- | :--- | | **pluginId** | `string` | ✅ | Unique plugin identifier | | **source** | `{ type: Enum<'npm' \| 'local' \| 'url' \| 'registry' \| 'git'>; location: string; version?: string; integrity?: string }` | ✅ | Plugin source location for dynamic resolution | -| **activationEvents** | `{ type: Enum<'onCommand' \| 'onRoute' \| 'onObject' \| 'onEvent' \| 'onService' \| 'onSchedule' \| 'onStartup'>; pattern: string }[]` | optional | Lazy activation triggers; if omitted plugin starts immediately | +| **activationEvents** | `{ type: Enum<'onCommand' \| 'onRoute' \| 'onObject' \| 'onEvent' \| 'onService' \| 'onSchedule' \| 'onStartup' \| 'onMetadataType' \| 'onView'>; pattern: string }[]` | optional | Lazy activation triggers; if omitted plugin starts immediately | | **config** | `Record` | optional | Runtime configuration overrides | | **priority** | `integer` | ✅ | Loading priority (lower is higher) | | **sandbox** | `boolean` | ✅ | Run in an isolated sandbox | diff --git a/content/docs/references/kernel/plugin.mdx b/content/docs/references/kernel/plugin.mdx index 69e86ea7e4..d9de5aae6c 100644 --- a/content/docs/references/kernel/plugin.mdx +++ b/content/docs/references/kernel/plugin.mdx @@ -16,27 +16,12 @@ These are the specialized plugin types common between Manifest (Package) and Plu ## TypeScript Usage ```typescript -import { ActivationEventSchema, PluginSchema } from '@objectstack/spec/kernel'; -import type { ActivationEvent } from '@objectstack/spec/kernel'; +import { PluginSchema } from '@objectstack/spec/kernel'; // Validate data -const result = ActivationEventSchema.parse(data); +const result = PluginSchema.parse(data); ``` ---- - -## ActivationEvent - -Lazy activation trigger for a dynamic plugin - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'onCommand' \| 'onRoute' \| 'onObject' \| 'onEvent' \| 'onService' \| 'onSchedule' \| 'onStartup'>` | ✅ | Trigger type for lazy activation | -| **pattern** | `string` | ✅ | Match pattern for the activation trigger | - - --- ## Plugin diff --git a/content/docs/references/studio/meta.json b/content/docs/references/studio/meta.json index 7dee856224..c8facffe04 100644 --- a/content/docs/references/studio/meta.json +++ b/content/docs/references/studio/meta.json @@ -4,6 +4,7 @@ "action", "flow-builder", "object-designer", - "plugin" + "plugin", + "plugin-runtime" ] } \ No newline at end of file diff --git a/content/docs/references/studio/plugin-runtime.mdx b/content/docs/references/studio/plugin-runtime.mdx new file mode 100644 index 0000000000..9fe31c2ac0 --- /dev/null +++ b/content/docs/references/studio/plugin-runtime.mdx @@ -0,0 +1,33 @@ +--- +title: Plugin Runtime +description: Plugin Runtime protocol schemas +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +## TypeScript Usage + +```typescript +import { ActivationEventSchema } from '@objectstack/spec/studio'; +import type { ActivationEvent } from '@objectstack/spec/studio'; + +// Validate data +const result = ActivationEventSchema.parse(data); +``` + +--- + +## ActivationEvent + +Lazy activation trigger for a dynamic plugin + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **type** | `Enum<'onCommand' \| 'onRoute' \| 'onObject' \| 'onEvent' \| 'onService' \| 'onSchedule' \| 'onStartup' \| 'onMetadataType' \| 'onView'>` | ✅ | Trigger type for lazy activation | +| **pattern** | `string` | ✅ | Match pattern for the activation trigger | + + +--- + diff --git a/content/docs/references/studio/plugin.mdx b/content/docs/references/studio/plugin.mdx index 6895103596..a55ef5b35e 100644 --- a/content/docs/references/studio/plugin.mdx +++ b/content/docs/references/studio/plugin.mdx @@ -100,7 +100,7 @@ modes: ['preview', 'design', 'data'], ## TypeScript Usage ```typescript -import { ActionContributionSchema, ActivationEventSchema, CommandContributionSchema, MetadataIconContributionSchema, MetadataViewerContributionSchema, PanelContributionSchema, PanelLocationSchema, SidebarGroupContributionSchema, StudioPluginContributionsSchema, StudioPluginManifestSchema, ViewModeSchema } from '@objectstack/spec/studio'; +import { ActionContributionSchema, CommandContributionSchema, MetadataIconContributionSchema, MetadataViewerContributionSchema, PanelContributionSchema, PanelLocationSchema, SidebarGroupContributionSchema, StudioPluginContributionsSchema, StudioPluginManifestSchema, ViewModeSchema } from '@objectstack/spec/studio'; import type { ActionContribution, CommandContribution, MetadataIconContribution, MetadataViewerContribution, PanelContribution, SidebarGroupContribution, StudioPluginContributions, StudioPluginManifest, ViewMode } from '@objectstack/spec/studio'; // Validate data @@ -122,9 +122,6 @@ const result = ActionContributionSchema.parse(data); | **metadataTypes** | `string[]` | ✅ | Applicable metadata types | ---- - - --- ## CommandContribution @@ -237,7 +234,7 @@ const result = ActionContributionSchema.parse(data); | **description** | `string` | optional | Plugin description | | **author** | `string` | optional | Author | | **contributes** | `{ metadataViewers: { id: string; metadataTypes: string[]; label: string; priority: number; … }[]; sidebarGroups: { key: string; label: string; icon?: string; metadataTypes: string[]; … }[]; actions: { id: string; label: string; icon?: string; location: Enum<'toolbar' \| 'contextMenu' \| 'commandPalette'>; … }[]; metadataIcons: { metadataType: string; label: string; icon: string }[]; … }` | ✅ | | -| **activationEvents** | `string[]` | ✅ | | +| **activationEvents** | `{ type: Enum<'onCommand' \| 'onRoute' \| 'onObject' \| 'onEvent' \| 'onService' \| 'onSchedule' \| 'onStartup' \| 'onMetadataType' \| 'onView'>; pattern: string }[]` | ✅ | | --- diff --git a/packages/spec/PLUGIN_STANDARDS.md b/packages/spec/PLUGIN_STANDARDS.md index 101e39b753..75cd3716a4 100644 --- a/packages/spec/PLUGIN_STANDARDS.md +++ b/packages/spec/PLUGIN_STANDARDS.md @@ -162,7 +162,7 @@ Plugins can be loaded and unloaded at runtime **without restarting the kernel**: - **`DynamicLoadRequestSchema`** — Load a plugin from `npm`, `local`, `url`, `registry`, or `git` sources with optional integrity verification - **`DynamicUnloadRequestSchema`** — Graceful/forceful/drain unload with dependency awareness (`cascade`, `warn`, or `block` dependents) -- **`ActivationEventSchema`** — Lazy activation triggers: `onCommand`, `onRoute`, `onObject`, `onEvent`, `onService`, `onSchedule`, `onStartup` +- **`ActivationEventSchema`** — Lazy activation triggers, shaped `{ type, pattern }`. Types: `onCommand`, `onRoute`, `onObject`, `onEvent`, `onService`, `onSchedule`, `onStartup`, `onMetadataType`, `onView`. Since v17 this is the platform's **single** activation vocabulary — `@objectstack/spec/studio` re-exports this exact declaration rather than carrying its own `z.string()` (#4653) - **`PluginDiscoveryConfigSchema`** — Runtime discovery from registries and local directories with polling and trust filtering - **`DynamicLoadingConfigSchema`** — Subsystem configuration: max dynamic plugins, default sandbox policy, allowed sources, integrity requirements diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index c92efda61b..37954a3178 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -4045,6 +4045,7 @@ "ActionContribution (type)", "ActionContributionSchema (const)", "ActionLocationSchema (const)", + "ActivationEvent (type)", "ActivationEventSchema (const)", "BUILT_IN_NODE_DESCRIPTORS (const)", "CommandContribution (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index c9857043d7..0558a0e85e 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -1,5 +1,5 @@ { - "description": "Ratchet of every AUTHORABLE key in the spec \u2014 what a metadata author may write, which for this platform IS the third-party API. Auto-updated on additions (commit the change). A key that disappears without a tombstone fails gen:schema, because these schemas are not .strict() and Zod would silently strip it. \"[RETIRED]\" marks a tombstoned key that still rejects with an upgrade prescription. See #3855, ADR-0059 \u00a75.", + "description": "Ratchet of every AUTHORABLE key in the spec — what a metadata author may write, which for this platform IS the third-party API. Auto-updated on additions (commit the change). A key that disappears without a tombstone fails gen:schema, because these schemas are not .strict() and Zod would silently strip it. \"[RETIRED]\" marks a tombstoned key that still rejects with an upgrade prescription. See #3855, ADR-0059 §5.", "keys": [ "ai/AIModelConfig:maxTokens", "ai/AIModelConfig:model", @@ -5422,6 +5422,8 @@ "studio/ActionContribution:label", "studio/ActionContribution:location", "studio/ActionContribution:metadataTypes", + "studio/ActivationEvent:pattern", + "studio/ActivationEvent:type", "studio/CommandContribution:icon", "studio/CommandContribution:id", "studio/CommandContribution:label", diff --git a/packages/spec/docs-import-surface.baseline.json b/packages/spec/docs-import-surface.baseline.json index d49939a787..5760f5b7d0 100644 --- a/packages/spec/docs-import-surface.baseline.json +++ b/packages/spec/docs-import-surface.baseline.json @@ -88,7 +88,6 @@ "shared/MutationEventEnum — no type export", "shared/SortDirectionEnum — no type export", "studio/ActionLocation — no type export", - "studio/ActivationEvent — no type export", "studio/PanelLocation — no type export", "system/AddFieldOperation — no type export", "system/CreateObjectOperation — no type export", diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json index 06bf9369b3..4f054737cc 100644 --- a/packages/spec/dual-source-exports.baseline.json +++ b/packages/spec/dual-source-exports.baseline.json @@ -2,7 +2,6 @@ "_comment": "Accepted cross-entry DUAL-SOURCE exports of @objectstack/spec (#4446): names that two or more public entry points export for DIFFERENT declarations, so which type a consumer gets depends on the import path — the #4411 trap. Shrink-only ratchet, judged by symbol identity (a re-export of one declaration from many entries is fine and not listed). A NEW name here fails check:dual-source-exports: converge on one declaration and re-export it, or rename one side — growing this list needs maintainer sign-off and shows up as this file in the diff. An entry that stops being dual-source fails until its line is deleted. Regenerate with: tsx scripts/check-dual-source-exports.ts --update (after pnpm build).", "entries": [ "ActionLocationSchema — [./studio (const)] ≠ [./ui (const)]", - "ActivationEventSchema — [./kernel (const)] ≠ [./studio (const)]", "ConflictResolution — [./automation (type)] ≠ [./integration (type)] ≠ [./ui (type)]", "ConflictResolutionSchema — [./automation (const)] ≠ [./integration (const)] ≠ [./ui (const)]", "DataSyncConfig — [./automation (type)] ≠ [./integration (type)]", diff --git a/packages/spec/src/kernel/plugin-runtime.test.ts b/packages/spec/src/kernel/plugin-runtime.test.ts index aab3d714fd..2f41f38bac 100644 --- a/packages/spec/src/kernel/plugin-runtime.test.ts +++ b/packages/spec/src/kernel/plugin-runtime.test.ts @@ -88,12 +88,32 @@ describe('Plugin Runtime Management Protocol', () => { const types = [ 'onCommand', 'onRoute', 'onObject', 'onEvent', 'onService', 'onSchedule', 'onStartup', + // [#4653] Widened to the union of the two pre-v17 vocabularies when + // `./studio` converged onto this declaration. + 'onMetadataType', 'onView', ]; types.forEach((type) => { const result = ActivationEventSchema.parse({ type, pattern: '*' }); expect(result.type).toBe(type); }); }); + + // [#4653] The whole point of converging on the structured form: a mistyped + // trigger is rejected at authoring time. The pre-v17 studio `z.string()` + // accepted every one of these silently. + it('rejects a mistyped trigger instead of silently accepting it', () => { + for (const type of ['onMetadatType', 'onview', 'banana', '']) { + expect(() => ActivationEventSchema.parse({ type, pattern: 'flow' })).toThrow(); + } + }); + + // [#4653] The studio string form is not silently coerced — it fails loudly. + // That is the migration's whole failure mode, so it is pinned here. + it('rejects the pre-v17 studio string form', () => { + for (const legacy of ['*', 'onMetadataType:flow', 'onCommand:my.cmd']) { + expect(() => ActivationEventSchema.parse(legacy)).toThrow(); + } + }); }); describe('DynamicLoadRequestSchema', () => { diff --git a/packages/spec/src/kernel/plugin-runtime.zod.ts b/packages/spec/src/kernel/plugin-runtime.zod.ts index 73008270a1..5c31c29d57 100644 --- a/packages/spec/src/kernel/plugin-runtime.zod.ts +++ b/packages/spec/src/kernel/plugin-runtime.zod.ts @@ -70,6 +70,37 @@ export const PluginSourceSchema = lazySchema(() => z.object({ * Activation Event * Defines when a dynamically available plugin should be activated. * Plugins remain dormant until an activation event fires. + * + * [#4653] **This is the platform's single activation vocabulary.** Until v17 + * the name `ActivationEventSchema` resolved to two DIFFERENT declarations + * depending on the import path (#4411's trap): this structured + * `{ type, pattern }` on `./kernel`, and a bare `z.string()` on `./studio`. + * A studio plugin author wrote `activationEvents: ['onMetadataType:flow']` and + * got no validation at all — `z.string()` accepts `'onMetadatType:flow'`, and + * every other typo, forever. `./studio` now re-exports THIS declaration, so + * there is one trigger vocabulary and one place to extend it. + * + * The enum below is the **union of both sides' pre-v17 vocabularies**, because + * dropping either side's values would have silently removed a capability its + * authors were already using: + * + * | value | came from | + * |:-----------------|:-----------------------------------------------------------| + * | `onCommand` | kernel enum + studio docs (`onCommand:myPlugin.doSomething`) | + * | `onRoute` | kernel enum | + * | `onObject` | kernel enum | + * | `onEvent` | kernel enum | + * | `onService` | kernel enum | + * | `onSchedule` | kernel enum | + * | `onStartup` | kernel enum; also the target of studio's eager `'*'` | + * | `onMetadataType` | studio docs/tests (`onMetadataType:object`) — kernel lacked it | + * | `onView` | studio docs/tests (`onView:myPlugin.myPanel`) — kernel lacked it | + * + * Deliberately NOT adopted: `priority`, and the `onInstall` / `onWebhook` + * values that cloud-v1's unreleased marketplace runtime carries. Nothing in + * any repo reads them, and adding an unenforced key is the exact debt ADR-0049 + * is retiring — they can be proposed when there is an executor that honours + * them. */ export const ActivationEventSchema = lazySchema(() => z.object({ /** @@ -82,11 +113,18 @@ export const ActivationEventSchema = lazySchema(() => z.object({ 'onEvent', // Activate when a system event fires 'onService', // Activate when a service is requested 'onSchedule', // Activate on a cron schedule - 'onStartup', // Activate immediately on kernel startup + 'onStartup', // Activate immediately on startup (eager) + 'onMetadataType', // Activate when a metadata type is loaded + 'onView', // Activate when a view / panel is opened ]).describe('Trigger type for lazy activation'), - + /** * Pattern to match (command name, route glob, object name, event pattern, etc.) + * + * The pre-v17 studio string form packed this into the same token after a + * colon — `'onCommand:myPlugin.doSomething'` is `{ type: 'onCommand', + * pattern: 'myPlugin.doSomething' }`, and eager `'*'` is + * `{ type: 'onStartup', pattern: '*' }`. */ pattern: z.string().describe('Match pattern for the activation trigger'), }).describe('Lazy activation trigger for a dynamic plugin')); diff --git a/packages/spec/src/studio/index.ts b/packages/spec/src/studio/index.ts index 34e4a1f382..f82f10b4dc 100644 --- a/packages/spec/src/studio/index.ts +++ b/packages/spec/src/studio/index.ts @@ -23,6 +23,9 @@ export { PanelLocationSchema, CommandContributionSchema, StudioPluginContributionsSchema, + // [#4653] `ActivationEventSchema` / `ActivationEvent` are RE-EXPORTS of the + // single declaration in `kernel/plugin-runtime.zod.ts`, not a second source. + // Studio plugin authors keep importing them from `@objectstack/spec/studio`. ActivationEventSchema, StudioPluginManifestSchema, @@ -36,6 +39,7 @@ export { type CommandContribution, type StudioPluginContributions, type StudioPluginManifest, + type ActivationEvent, // Helpers defineStudioPlugin, diff --git a/packages/spec/src/studio/plugin.test.ts b/packages/spec/src/studio/plugin.test.ts index 2bb86fdfef..6ffea20fba 100644 --- a/packages/spec/src/studio/plugin.test.ts +++ b/packages/spec/src/studio/plugin.test.ts @@ -202,15 +202,30 @@ describe('StudioPluginContributionsSchema', () => { describe('ActivationEventSchema', () => { it('should accept valid activation events', () => { - const events = ['*', 'onMetadataType:object', 'onCommand:myPlugin.do', 'onView:myPanel']; + // [#4653] The four events this file documented pre-v17, in the structured + // form they converged onto. Every one still expresses what it used to. + const events = [ + { type: 'onStartup', pattern: '*' }, // was '*' + { type: 'onMetadataType', pattern: 'object' }, // was 'onMetadataType:object' + { type: 'onCommand', pattern: 'myPlugin.do' }, // was 'onCommand:myPlugin.do' + { type: 'onView', pattern: 'myPanel' }, // was 'onView:myPanel' + ]; events.forEach(e => { expect(() => ActivationEventSchema.parse(e)).not.toThrow(); }); }); - it('should reject non-string', () => { + it('should reject the pre-v17 bare-string form', () => { + // Loud, not silently coerced — the manual migration depends on this. + expect(() => ActivationEventSchema.parse('onMetadataType:object')).toThrow(); + expect(() => ActivationEventSchema.parse('*')).toThrow(); expect(() => ActivationEventSchema.parse(123)).toThrow(); }); + + it('should reject an unknown trigger type', () => { + // The capability the old `z.string()` declaration could never provide. + expect(() => ActivationEventSchema.parse({ type: 'onMetadatType', pattern: 'flow' })).toThrow(); + }); }); describe('StudioPluginManifestSchema', () => { @@ -222,7 +237,8 @@ describe('StudioPluginManifestSchema', () => { it('should accept minimal manifest with defaults', () => { const result = StudioPluginManifestSchema.parse(minimalManifest); expect(result.version).toBe('0.0.1'); - expect(result.activationEvents).toEqual(['*']); + // [#4653] Eager activation, structured. FROM `['*']`. + expect(result.activationEvents).toEqual([{ type: 'onStartup', pattern: '*' }]); expect(result.contributes).toBeDefined(); expect(result.description).toBeUndefined(); expect(result.author).toBeUndefined(); @@ -244,11 +260,22 @@ describe('StudioPluginManifestSchema', () => { modes: ['preview', 'design', 'data'], }], }, - activationEvents: ['onMetadataType:object'], + activationEvents: [{ type: 'onMetadataType', pattern: 'object' }], }; expect(() => StudioPluginManifestSchema.parse(manifest)).not.toThrow(); }); + it('rejects a manifest still carrying the pre-v17 string activation events', () => { + // The migration is manual (no conversion can reach a studio plugin + // manifest — it is a root schema, never part of a stack), so the ONLY + // thing standing between a stale manifest and a wrong-shaped plugin is + // this parse failing. Pinned so it can never soften into a coercion. + expect(() => StudioPluginManifestSchema.parse({ + ...minimalManifest, + activationEvents: ['onMetadataType:object'], + })).toThrow(); + }); + it('should reject invalid id format', () => { expect(() => StudioPluginManifestSchema.parse({ id: 'Invalid ID!', name: 'Test' })).toThrow(); expect(() => StudioPluginManifestSchema.parse({ id: 'UPPERCASE', name: 'Test' })).toThrow(); @@ -276,10 +303,83 @@ describe('defineStudioPlugin', () => { }); expect(result.id).toBe('objectstack.flow-designer'); expect(result.version).toBe('0.0.1'); - expect(result.activationEvents).toEqual(['*']); + // [#4653] FROM `['*']` — eager activation, now structured. + expect(result.activationEvents).toEqual([{ type: 'onStartup', pattern: '*' }]); }); it('should throw on invalid input', () => { expect(() => defineStudioPlugin({ id: 'BAD ID', name: 'Test' })).toThrow(); }); }); + +// ─── [#4653] Dual-source regression pin ────────────────────────────── +// +// RUNTIME assertions, deliberately. #4642 established that a compile-time pin +// in `packages/spec` is a no-op: `tsconfig.json` excludes `**/*.test.ts` and +// `vitest.config.ts` never enables `typecheck`, so neither path type-checks a +// test file — a conditional-type pin here would be dead text. These run. +// +// What they defend: `ActivationEventSchema` naming ONE declaration across both +// published entries. Re-introducing a local declaration in `studio/plugin.zod.ts` +// (the pre-v17 `z.string()`, or any other) re-creates the #4411 trap where the +// validation an author gets depends on which subpath they imported from, and +// puts the name straight back on `dual-source-exports.baseline.json`. +describe('[#4653] ActivationEventSchema is single-source across ./kernel and ./studio', () => { + it('both entry points export the very same declaration', async () => { + const kernelEntry = await import('../kernel/index'); + const studioEntry = await import('../studio/index'); + + // Identity, not shape: `lazySchema` returns one Proxy per declaration site, + // so two declarations can never be `toBe`-equal however alike they look. + // This is exactly what check:dual-source-exports measures (symbol identity + // after alias resolution) — asserted here at runtime so a re-split fails + // `pnpm test` too, not only the gate. + expect(studioEntry.ActivationEventSchema).toBe(kernelEntry.ActivationEventSchema); + }); + + it('the shared declaration is the structured kernel form on BOTH entries', async () => { + const kernelEntry = await import('../kernel/index'); + const studioEntry = await import('../studio/index'); + + for (const [entry, schema] of [ + ['./kernel', kernelEntry.ActivationEventSchema], + ['./studio', studioEntry.ActivationEventSchema], + ] as const) { + // Structured form accepted... + expect( + schema.parse({ type: 'onMetadataType', pattern: 'flow' }), + `${entry} must accept the structured form`, + ).toEqual({ type: 'onMetadataType', pattern: 'flow' }); + // ...bare string rejected, on both paths, identically. + expect( + () => schema.parse('onMetadataType:flow'), + `${entry} must reject the pre-v17 string form`, + ).toThrow(); + } + }); + + it('the trigger vocabulary is the union of both pre-v17 vocabularies', async () => { + const studioEntry = await import('../studio/index'); + // 7 from kernel + `onMetadataType` / `onView` rescued from studio's docs. + // Losing either of the last two would silently drop a capability studio + // authors were already using. + const union = [ + 'onCommand', 'onRoute', 'onObject', 'onEvent', + 'onService', 'onSchedule', 'onStartup', + 'onMetadataType', 'onView', + ]; + for (const type of union) { + expect( + () => studioEntry.ActivationEventSchema.parse({ type, pattern: '*' }), + `'${type}' must stay in the vocabulary`, + ).not.toThrow(); + } + // And it is exactly that set — a tenth value would mean an undeclared + // vocabulary change slipped in (e.g. cloud-v1's `onInstall` / `onWebhook`, + // deliberately not adopted: nothing reads them, see ADR-0049 / #4657). + const options = (studioEntry.ActivationEventSchema as unknown as { + shape: { type: { options: string[] } }; + }).shape.type.options; + expect([...options].sort()).toEqual([...union].sort()); + }); +}); diff --git a/packages/spec/src/studio/plugin.zod.ts b/packages/spec/src/studio/plugin.zod.ts index bf7c955493..4fd4c03da5 100644 --- a/packages/spec/src/studio/plugin.zod.ts +++ b/packages/spec/src/studio/plugin.zod.ts @@ -61,6 +61,8 @@ import { z } from 'zod'; /** Supported view modes for metadata viewers */ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; +// [#4653] The one activation vocabulary — see the note above the re-export below. +import { ActivationEventSchema } from '../kernel/plugin-runtime.zod'; /** * Shared history for this file (#4001). @@ -273,16 +275,31 @@ export type StudioPluginContributions = z.infer z.string().describe('Activation event pattern')); +export { ActivationEventSchema, type ActivationEvent } from '../kernel/plugin-runtime.zod'; // ─── Studio Plugin Manifest ────────────────────────────────────────── @@ -351,11 +368,17 @@ export const StudioPluginManifestSchema = lazySchema(() => strictObject({ commands: [], }), - /** + /** * Activation events — when to load this plugin. - * Default `['*']` means eager activation. + * + * [#4653] The default is the structured equivalent of the pre-v17 `['*']`: + * eager activation. `'*'` did not need its own `type` — it always meant + * "activate immediately", which is exactly what `onStartup` already means on + * the kernel side, so eager survives as `onStartup` with the `'*'` pattern + * rather than as a tenth enum value that would duplicate it. */ - activationEvents: z.array(ActivationEventSchema).default(['*']), + activationEvents: z.array(ActivationEventSchema) + .default([{ type: 'onStartup', pattern: '*' }]), })); export type StudioPluginManifest = z.infer;