diff --git a/.changeset/24471-extension-guidance-hooks-list-views.md b/.changeset/24471-extension-guidance-hooks-list-views.md new file mode 100644 index 00000000000..3a506f2ff59 --- /dev/null +++ b/.changeset/24471-extension-guidance-hooks-list-views.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': patch +--- + +Clause-②: no + +fix(spec): the object-extension refusals for `hooks` and `listViews` say what `defineStack()` does with a hook or view on another package's object + +`ObjectExtensionSchema` refuses `hooks` and `listViews` on an `objectExtensions` entry. Both refusals used to tell the author to declare a top-level hook, or a top-level `view`, "bound to the target object instead". For an object the stack does not define, `defineStack()` refuses both at build, an extended object included, so the route the refusal named could not be taken. + +- **`hooks`** now reads: a hook follows the ownership of its object, so declare it as a top-level hook in the stack that defines the object; `defineStack()` refuses a top-level hook bound to an object the stack does not define, an extended one included. The refusal is the recorded decision of ADR-0130 §1.5 ("the split must follow hook ownership"). +- **`listViews`** now reads: declare the views as a top-level `view` in the stack that defines the target object; `defineStack()` refuses a top-level `view` whose `list` or `form` data source is an object the stack does not define, an extended one included. +- **Unchanged.** What `defineStack()` accepts and refuses, every error code, status and issue path. Only the refusal text moves, with its mirror in the object-extensions guide. diff --git a/content/docs/data-modeling/object-extensions.mdx b/content/docs/data-modeling/object-extensions.mdx index 5c35644d1b7..e177b38acb0 100644 --- a/content/docs/data-modeling/object-extensions.mdx +++ b/content/docs/data-modeling/object-extensions.mdx @@ -83,8 +83,8 @@ each one by name with the alternative to use instead: | Key you might reach for | Declare this instead | |:---|:---| | `actions` | A top-level action with `objectName: ''` in the same stack — it stays in the stack's top-level `actions`, and the engine's schema registry attaches it to the target object wherever the object is served, whichever package registers first; a target no loaded package registers fails the boot | -| `hooks` | A top-level hook bound to the target object | -| `listViews` | A top-level `view` bound to the target object | +| `hooks` | A top-level hook in the stack that defines the target object — a hook follows the ownership of its object, and `defineStack()` refuses a top-level hook bound to an object the stack does not define, an extended one included | +| `listViews` | A top-level `view` in the stack that defines the target object — `defineStack()` refuses a top-level `view` whose `list` or `form` data source is an object the stack does not define, an extended one included | | `fieldGroups` | Add the fields here and declare the groups on the owning object, or assign a Page for the layout | The same holds for any other key: the extension schema is strict, so an unknown key diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 7ae6106fe87..210ac17b1a0 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -3996,12 +3996,22 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({ 'top-level `actions`, and the engine\'s schema registry attaches it to the target ' + 'object wherever the object is served, whichever package registers first. A target no ' + 'loaded package registers fails the boot.', + // `hooks` and `listViews` name only what `defineStack()` measurably does. + // Its cross-reference check (`validateCrossReferences` in `stack.zod.ts`) + // judges a top-level hook's `object`, and a top-level view's `list` / + // `form` data source, against the objects the stack itself defines — an + // extended target is not one of them. For hooks that refusal is a recorded + // decision, ADR-0130 §1.5: "the split must follow hook ownership". hooks: - '`hooks` cannot be contributed through an object extension — declare a top-level ' + - 'hook bound to the target object instead.', + '`hooks` cannot be contributed through an object extension — the merge does not carry ' + + 'them. A hook follows the ownership of its object: declare it as a top-level hook in ' + + 'the stack that defines the object. `defineStack()` refuses a top-level hook bound to ' + + 'an object the stack does not define, an extended one included.', listViews: - '`listViews` cannot be contributed through an object extension — declare a ' + - 'top-level `view` bound to the target object instead.', + '`listViews` cannot be contributed through an object extension — the merge does not ' + + 'carry them. Declare the views as a top-level `view` in the stack that defines the ' + + 'target object. `defineStack()` refuses a top-level `view` whose `list` or `form` data ' + + 'source is an object the stack does not define, an extended one included.', fieldGroups: '`fieldGroups` cannot be contributed through an object extension — the merge does ' + 'not carry them. Add the fields here and declare the groups on the owning object, ' + diff --git a/packages/spec/src/stack-extension-hook-view-foreign-object.test.ts b/packages/spec/src/stack-extension-hook-view-foreign-object.test.ts new file mode 100644 index 00000000000..22444a52b0f --- /dev/null +++ b/packages/spec/src/stack-extension-hook-view-foreign-object.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `ObjectExtensionSchema` refuses `hooks` and `listViews` on an extension and + * says what to do instead. Each sentence makes a claim about `defineStack()`, + * and this file holds each claim at `defineStack()` itself: + * + * - a top-level hook bound to an object the stack does not define is refused at + * build, even when the stack extends that object through `objectExtensions`. + * ADR-0130 §1.5 records that refusal as deliberate: "the split must follow + * hook ownership". A hook on an object the stack defines is accepted; + * - a top-level view whose `list` or `form` data source is an object the stack + * does not define is refused the same way, extended or not. A view on an + * object the stack defines is accepted. + * + * Both refusals come from `validateCrossReferences` in `stack.zod.ts`, which + * keeps exactly what it accepts and refuses; only the guidance text names it. + */ + +import { describe, it, expect } from 'vitest'; +import { defineStack } from './stack.zod'; +import { ObjectExtensionSchema } from './data/object.zod'; + +const manifest = { id: 'com.test.extender', name: 'extender', version: '1.0.0', type: 'app' as const }; +const OWN = { name: 'extender_note', label: 'Note', fields: { title: { type: 'text' as const, label: 'Title' } } }; +const FOREIGN = 'sys_organization'; +const extendsForeign = [{ extend: FOREIGN, fields: { tier: { type: 'text' as const, label: 'Tier' } } }]; + +const hook = (object: string) => ({ name: 'probe_hook', object, events: ['beforeInsert'], handler: 'noop' }); +const listView = (object: string) => ({ + list: { type: 'grid', data: { provider: 'object', object }, columns: ['name'] }, +}); +const formView = (object: string) => ({ + form: { type: 'simple', data: { provider: 'object', object }, sections: [{ label: 'Main', fields: [{ field: 'name' }] }] }, +}); + +const refusalOf = (config: unknown): any => { + try { + defineStack(config as any); + return undefined; + } catch (error) { + return error; + } +}; + +describe('defineStack and a top-level hook bound to an object outside the stack', () => { + it('refuses a hook on an object the stack only extends', () => { + const err = refusalOf({ manifest, objects: [OWN], objectExtensions: extendsForeign, hooks: [hook(FOREIGN)] }); + expect(err).toMatchObject({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 }); + expect(err.message).toContain(`Hook 'probe_hook' references object '${FOREIGN}'`); + }); + + it('accepts a hook on an object the stack defines (the control)', () => { + const stack = defineStack({ + manifest, objects: [OWN], objectExtensions: extendsForeign, hooks: [hook(OWN.name)], + } as any); + expect(stack.hooks?.map((h) => h.object)).toEqual([OWN.name]); + }); +}); + +describe('defineStack and a top-level view whose data source is an object outside the stack', () => { + it('refuses a view whose `list` reads an object the stack only extends', () => { + const err = refusalOf({ manifest, objects: [OWN], objectExtensions: extendsForeign, views: [listView(FOREIGN)] }); + expect(err).toMatchObject({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 }); + expect(err.message).toContain(`View[0].list references object '${FOREIGN}'`); + }); + + it('refuses a view whose `form` reads an object the stack only extends', () => { + const err = refusalOf({ manifest, objects: [OWN], objectExtensions: extendsForeign, views: [formView(FOREIGN)] }); + expect(err).toMatchObject({ code: 'STACK_CROSS_REFERENCE_INVALID', status: 422 }); + expect(err.message).toContain(`View[0].form references object '${FOREIGN}'`); + }); + + it('accepts a view on an object the stack defines (the control)', () => { + const stack = defineStack({ + manifest, objects: [OWN], objectExtensions: extendsForeign, views: [listView(OWN.name)], + } as any); + expect(stack.views).toHaveLength(1); + }); +}); + +describe('the extension refusal says what defineStack does', () => { + const guidanceFor = (key: 'hooks' | 'listViews') => { + const parsed = ObjectExtensionSchema.safeParse({ extend: FOREIGN, [key]: key === 'hooks' ? [] : {} }); + expect(parsed.success).toBe(false); + return JSON.stringify(parsed.error?.issues); + }; + + it('`hooks` names the owning stack and the refusal of an extended target', () => { + const text = guidanceFor('hooks'); + expect(text).toContain('declare it as a top-level hook in the stack that defines the object'); + expect(text).toContain('refuses a top-level hook bound to an object the stack does not define, an extended one included'); + }); + + it('`listViews` names the owning stack and the refusal of an extended target', () => { + const text = guidanceFor('listViews'); + expect(text).toContain('top-level `view` in the stack that defines the target object'); + expect(text).toContain('data source is an object the stack does not define, an extended one included'); + }); +});