diff --git a/scripts/cordis-include-journal.spec.ts b/scripts/cordis-include-journal.spec.ts index f39ae5c1b4..a98050dc84 100644 --- a/scripts/cordis-include-journal.spec.ts +++ b/scripts/cordis-include-journal.spec.ts @@ -177,3 +177,28 @@ it('rejects a cross-tree move onto an id the destination already holds', async ( await app.include.flush() expect(await app.read()).toEqual([{ id: 'g', name: 'cordis:group', group: true, config: [row('x', 1)] }]) }) + +it('transfers the members of a group moved into the Include', async () => { + const app = await fixture([{ id: 'g', name: 'cordis:group', group: true, config: [] }]) + const apply = vi.fn() + app.ctx.loader.builtins.counted = apply + const outer = await app.ctx.loader.create({ name: 'cordis:group', group: true, config: [] }) + await app.ctx.loader.await() + const inner = await app.ctx.loader.create({ name: 'cordis:counted', config: { value: 1 } }, outer) + await app.ctx.loader.await() + const [group, member] = [app.ctx.loader.store[outer]!, app.ctx.loader.store[inner]!] + await app.ctx.loader.update(outer, {}, `${app.id}:g`) + await app.include.flush() + await app.ctx.loader.await() + expect(app.include.store[outer] === group && app.include.store[inner] === member).toBe(true) + expect(outer in app.ctx.loader.store || inner in app.ctx.loader.store).toBe(false) + expect(group.subgroup!.tree === app.include).toBe(true) + expect(apply).toHaveBeenCalledOnce() + expect(await app.read()).toEqual([{ id: 'g', name: 'cordis:group', group: true, config: [ + { id: outer, name: 'cordis:group', group: true, config: [{ id: inner, name: 'cordis:counted', config: { value: 1 } }] }, + ] }]) + app.ctx.loader.remove(`${app.id}:${outer}`) + await app.include.flush() + await app.ctx.loader.await() + expect(member.fiber?.state).not.toBe(FiberState.ACTIVE) +}) diff --git a/vendor/README.md b/vendor/README.md index bb1888ba49..084761deea 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -49,7 +49,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. Each - **V23 · `loader/src/config/entry.ts` module identity**: retains each entry's raw import result as `moduleNamespace`, before plugin export normalization. HMR matches that object to Node's cached module namespace and updates it after a successful reload. `EntryTree.import()` keeps its existing arguments and return value; `loader/README.md` documents the additive field. - **V24 · `timer/locale/{en,zh}.json` display metadata**: local English and Chinese titles and descriptions for the Settings plugin inventory. `timer/package.json` exports and publishes these locale resources. - **V25 · Loader lifecycle adaptations**: before a combined context/config update, Loader refreshes dependencies locally, which prevents activation against stale isolation bindings; disabled-entry updates await disposal. Failed fibers require an explicit update after correcting their input or dependencies. Core retains the effect hardening in V6; rc.11 owns dependency teardown ordering and superseded-activation handling. -- **V26 · `loader/src/config/tree.ts` cross-tree moves**: `EntryTree.update()` moves an entry's store registration with it when the destination group belongs to another tree, and rejects the move before changing either tree when the destination tree already holds the id. It reports a removal to the source tree and a creation, without `legacy` or `from`, to the destination tree. Upstream keeps the entry registered in the source tree and reports the destination as an update with `legacy`; the Include journal then writes a partial row, and re-applying the destination file creates a second Entry for the same id. Covered by `scripts/cordis-include-journal.spec.ts`. +- **V26 · `loader/src/config/tree.ts` cross-tree moves**: backports [cordiverse/cordis#271](https://github.com/cordiverse/cordis/pull/271), merged after `v4.0.0-rc.11`. When `EntryTree.update()` moves an entry into a group of another tree, the entry and the same-tree members of its group move their store registrations to the target tree, and the move is rejected before either tree changes when the target tree already holds one of their ids. The source tree receives a removal and the target tree a creation, without `legacy` or `from`. In rc.11 the entry stays registered in the source tree and the target receives an update with `legacy`, so the Include journal writes a partial row and reconciling the target file creates a second Entry. Retire this entry when the vendored Loader includes the upstream fix. Covered by `scripts/cordis-include-journal.spec.ts`. - **V27 · `loader/src/config/tree.ts` explicit ids with `:`**: `ensureId()` accepts explicit ids containing `EntryTree.sep`; upstream rejects them. Generated bundles from `@deepseek-ai/dsh-creation-recommendations` prefix their row ids with `:`, and installed profiles already contain such rows. `resolve()` still splits ids on `:`, so these entries cannot be addressed by nested path. Covered by `scripts/cordis-entry-commit.spec.ts` and `packages/experimental/creation-recommendations/tests/loader-composition.spec.ts`. ### Resolver adaptations diff --git a/vendor/loader/README.md b/vendor/loader/README.md index ddfe7f091e..2b71deba01 100644 --- a/vendor/loader/README.md +++ b/vendor/loader/README.md @@ -68,4 +68,4 @@ Entry updates await `loader/patch-context`, the Fiber update waterfall and disab ## Tree mutation reports -`EntryTree.commit(change)` receives each mutation made through `EntryTree` methods synchronously after options and group membership change; file-driven `EntryGroup` reconciliation does not report. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move within the tree; a move into another tree transfers the entry's registration, reports a removal to the source tree and a creation to the destination tree, and is rejected when the destination tree already holds the id. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; Include journals the reports and reconciles them with file edits. Explicit entry ids may contain `:`; `resolve()` cannot address such entries by nested path, because `:` separates nested tree paths. +`EntryTree.commit(change)` receives each mutation made through `EntryTree` methods synchronously after options and group membership change; file-driven `EntryGroup` reconciliation does not report. A missing `legacy` means creation; missing `options` means removal. Updates carry both, with `from` naming the previous group after a move within the tree; a move into another tree transfers the registrations of the entry and its group members, reports a removal to the source tree and a creation to the destination tree, and is rejected when the destination tree already holds the id. Explicit Fiber saves report their simplified configuration. File-driven reconciliation, parent teardown and Loader removal do not produce spurious self-disable reports. In-memory trees implement a no-op; Include journals the reports and reconciles them with file edits. Explicit entry ids may contain `:`; `resolve()` cannot address such entries by nested path, because `:` separates nested tree paths. diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 33482b6c6a..5b5648f576 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -27,6 +27,19 @@ export interface EntryChange { legacy?: EntryOptions } +/** The entry and the same-tree members of its group, recursively. */ +function members(entry: Entry): Entry[] { + const { tree } = entry.parent + const result = [entry] + const group = entry.subgroup + if (group?.tree !== tree) return result + for (const options of group.data) { + const member = tree.store[options.id] + if (member?.parent === group) result.push(...members(member)) + } + return result +} + /** Mutable tree of loader entries. Persistence is supplied by subclasses. */ export abstract class EntryTree { static readonly sep = ':' @@ -134,29 +147,34 @@ export abstract class EntryTree { const legacy = { ...entry.options } if (parent !== undefined) { const target = this.resolveGroup(parent) - if (target.tree !== source.tree && target.tree.store[legacy.id]) { - throw new Error(`entry ${legacy.id} already exists in the target tree`) + const moved = target.tree === source.tree ? [] : members(entry) + for (const member of moved) { + const { id } = member.options + if (target.tree.store[id]) throw new Error(`entry ${id} already exists in the target tree`) } source.unlink(entry.options) target.data.splice(position ?? Infinity, 0, entry.options) entry.parent = target - // Each tree indexes its own entries, so a cross-tree move transfers the registration. - if (target.tree !== source.tree) { - delete source.tree.store[legacy.id] - target.tree.store[legacy.id] = entry + // The target tree owns the moved entries from now on, so that it adopts + // them instead of creating new ones when it reconciles its source. + for (const member of moved) { + delete source.tree.store[member.options.id] + target.tree.store[member.options.id] = member + if (member.subgroup?.tree === source.tree) member.subgroup.tree = target.tree } } // `Entry.update` assigns the new options before its first `await`, so the // change is fully visible to `commit()` once the call returns. const task = entry.update(options, false, true) if (entry.parent.tree !== source.tree) { + // a move across trees removes the entry from the source tree and + // creates it in the target tree source.tree.commit({ id: legacy.id, group: source, legacy }) - // The destination tree has no previous row, so it receives a creation. entry.parent.tree.commit({ id: legacy.id, group: entry.parent, options: entry.options }) - return task + } else { + const from = entry.parent === source ? undefined : source + entry.parent.tree.commit({ id: legacy.id, group: entry.parent, from, options: entry.options, legacy }) } - const from = entry.parent === source ? undefined : source - entry.parent.tree.commit({ id: legacy.id, group: entry.parent, from, options: entry.options, legacy }) return task }