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
13 changes: 13 additions & 0 deletions .changeset/24471-extension-guidance-hooks-list-views.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions content/docs/data-modeling/object-extensions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<target>'` 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
Expand Down
18 changes: 14 additions & 4 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, ' +
Expand Down
100 changes: 100 additions & 0 deletions packages/spec/src/stack-extension-hook-view-foreign-object.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading