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
5 changes: 5 additions & 0 deletions .changeset/quiet-fields-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@opensaas/stack-core': patch
---

`extendList` now throws when a plugin redeclares a field another plugin introduced, instead of silently discarding that field's access, hooks and options.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ export default config({
- **Extend Lists**: Add fields or hooks to existing lists
- **Declare Extension packs**: `context.addExtension({ name, from })`, so a field type needing a pack does not push that declaration onto the application
- **Hook Chaining**: Multiple plugins can add hooks that execute in sequence
- **Deep Merging**: Plugins safely merge fields, hooks, and access control
- **Merging**: Hooks chain (`mergeHooks`); fields are replaced per key, and a plugin may redeclare an app-declared field or its own, but redeclaring a field another plugin introduced throws; operation-level access on an existing list is refused (ADR-0013, ADR-0077)
- **Lifecycle Hooks**: `beforeGenerate`, `afterGenerate` for code generation control
- **Dependency Resolution**: Automatic execution ordering via topological sort

Expand Down
20 changes: 20 additions & 0 deletions docs/adr/0077-a-field-belongs-to-the-plugin-that-introduced-it.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# A field belongs to the plugin that introduced it

Status: accepted

The plugin engine records which plugin first introduced each field key (via `addList` or `extendList`); fields declared by the application belong to the app. An `extendList` from plugin P that redeclares a field owned by a **different plugin** throws a config error naming P, the owner, the list and the field. Redeclaring an app-declared field, or P's own field, is allowed and replaces per key as before.

## Context

`extendList` merges fields as `{ ...existing.fields, ...extension.fields }`, so a later plugin silently discarded an earlier plugin's field-level `access`, hooks and options. ADR-0013 closed the operation-level equivalent; this closes the field-level one. Plugins legitimately refine fields by redeclaring them (RAG defaults `dimensions` and wires `autoGenerate` on its own and app-declared `embedding()` fields), so a blanket refusal was not an option.

## Considered options

- **Ownership guard (chosen).** Loud, no composition semantics, leaves legitimate refinement intact.
- **Deep-merge a field's `access`.** Rejected: access rules return booleans or Where conditions, composing them is surprising (same reason as ADR-0013), and it still lets a plugin alter a field it does not own.
- **Opt-out flag.** Rejected: a plugin-held flag lets the plugin re-silence the overwrite, as in ADR-0013.

## Consequences

- Ownership is engine-internal state, not exposed on the resolved config.
- Hook chaining and the operation-access refusal are unchanged.
86 changes: 86 additions & 0 deletions packages/core/src/config/plugin-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,92 @@ describe('Plugin Engine', () => {
expect(result.lists.Post.fields.views).toBeDefined()
})

describe('field ownership', () => {
const run = (plugins: Plugin[], app = true) =>
executePlugins({
db: { provider: 'postgresql' },
lists: app ? { Post: { fields: { title: text() } } } : {},
plugins,
})
const a: Plugin = {
name: 'a',
init: async (c) => c.extendList('Post', { fields: { summary: text() } }),
}

test('throws when a plugin redeclares a field another plugin extended in', async () => {
const b: Plugin = {
name: 'b',
dependencies: ['a'],
init: async (c) => c.extendList('Post', { fields: { summary: text() } }),
}
await expect(run([a, b])).rejects.toThrow(
/Plugin "b".*"summary".*"Post".*introduced by plugin "a"/,
)
})

test('throws when the field was introduced through addList', async () => {
const adder: Plugin = {
name: 'a',
init: async (c) => c.addList('Note', { fields: { body: text() } }),
}
const b: Plugin = {
name: 'b',
dependencies: ['a'],
init: async (c) => c.extendList('Note', { fields: { body: text() } }),
}
await expect(run([adder, b])).rejects.toThrow(/"body".*"Note".*introduced by plugin "a"/)
})

test('allows a plugin to redeclare its own field', async () => {
const self: Plugin = {
name: 'a',
init: async (c) => {
c.extendList('Post', { fields: { summary: text() } })
c.extendList('Post', { fields: { summary: integer() } })
},
}
const result = await run([self])
expect(result.lists.Post.fields.summary.type).toBe('integer')
})

test('allows a plugin to redeclare an app-declared field', async () => {
const p: Plugin = {
name: 'p',
init: async (c) => c.extendList('Post', { fields: { title: integer() } }),
}
const result = await run([p])
expect(result.lists.Post.fields.title.type).toBe('integer')
})

test('keeps an app-declared field app-owned across successive plugin refinements', async () => {
const first: Plugin = {
name: 'first',
init: async (c) => c.extendList('Post', { fields: { title: integer() } }),
}
const second: Plugin = {
name: 'second',
dependencies: ['first'],
init: async (c) => c.extendList('Post', { fields: { title: text() } }),
}
const result = await run([first, second])
expect(result.lists.Post.fields.title.type).toBe('text')
})

test('allows a plugin to add a new field to a list another plugin created', async () => {
const adder: Plugin = {
name: 'a',
init: async (c) => c.addList('Note', { fields: { body: text() } }),
}
const b: Plugin = {
name: 'b',
dependencies: ['a'],
init: async (c) => c.extendList('Note', { fields: { tag: text() } }),
}
const result = await run([adder, b])
expect(result.lists.Note.fields.tag).toBeDefined()
})
})

test('throws when extending non-existent list', async () => {
const plugin: Plugin = {
name: 'test-plugin',
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/config/plugin-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,15 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo

const mcpToolsRegistry: McpCustomTool[] = []

// listKey -> fieldKey -> the plugin that introduced it. A field absent here
// belongs to the application.
const fieldOwners = new Map<string, Map<string, string>>()
const claimFields = (listKey: string, fieldKeys: string[], owner: string) => {
const owners = fieldOwners.get(listKey) ?? new Map<string, string>()
for (const key of fieldKeys) if (!owners.has(key)) owners.set(key, owner)
fieldOwners.set(listKey, owners)
}

for (const plugin of sortedPlugins) {
const context: PluginContext = {
config: currentConfig,
Expand All @@ -174,6 +183,7 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo
)
}
currentConfig.lists[name] = listConfig
claimFields(name, Object.keys(listConfig.fields), plugin.name)
},

extendList: (name, extension) => {
Expand Down Expand Up @@ -205,11 +215,29 @@ export async function executePlugins(config: OpenSaasConfig): Promise<OpenSaasCo
)
}

const owners = fieldOwners.get(name)
for (const fieldKey of Object.keys(extension.fields ?? {})) {
const owner = owners?.get(fieldKey)
if (owner !== undefined && owner !== plugin.name) {
throw new Error(
`Plugin "${plugin.name}" tried to redeclare field "${fieldKey}" on list "${name}", but that field ` +
`was introduced by plugin "${owner}". A field belongs to the plugin that introduced it — ` +
`redeclaring it would silently discard its access, hooks and options. See ADR-0013.`,
)
}
}

const mergedFields = {
...existing.fields,
...extension.fields,
}

claimFields(
name,
Object.keys(extension.fields ?? {}).filter((key) => !Object.hasOwn(existing.fields, key)),
plugin.name,
)

const mergedHooks = mergeHooks(existing.hooks, extension.hooks)

const mergedMcp = extension.mcp
Expand Down
Loading