@zhin.js/runtime 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -116,10 +116,36 @@ export class ConfigComposer {
116
116
  });
117
117
  }
118
118
  }
119
+ const FRAMEWORK_ROLE_SCHEMA = Object.freeze({
120
+ master: Object.freeze({
121
+ type: ['string', 'number'],
122
+ description: 'Endpoint owner (framework-level master role)',
123
+ }),
124
+ trusted: Object.freeze({
125
+ type: 'array',
126
+ items: Object.freeze({ type: ['string', 'number'] }),
127
+ description: 'Trusted user ID list (framework-level trusted role)',
128
+ }),
129
+ });
130
+ function hasArrayEndpoints(props) {
131
+ const ep = props.endpoints;
132
+ if (!ep || typeof ep !== 'object')
133
+ return false;
134
+ return ep.type === 'array';
135
+ }
119
136
  async function composeNode(node, ownSchemas) {
120
137
  const own = await readOwnSchema(node);
121
138
  ownSchemas.set(node.id, own);
122
139
  const properties = { ...schemaProperties(own) };
140
+ // Adapter plugins (schemas declaring array-typed `endpoints`) get
141
+ // framework-level `master` / `trusted` injected when not already declared.
142
+ if (hasArrayEndpoints(properties)) {
143
+ for (const [key, schema] of Object.entries(FRAMEWORK_ROLE_SCHEMA)) {
144
+ if (!Object.hasOwn(properties, key)) {
145
+ properties[key] = schema;
146
+ }
147
+ }
148
+ }
123
149
  for (const child of node.children) {
124
150
  if (Object.hasOwn(properties, child.instanceKey)) {
125
151
  throw new ConfigSchemaCollisionError(node.id, child.instanceKey);
@@ -0,0 +1,26 @@
1
+ import { type CapabilityId, type FeatureId, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import type { FallbackInvalidation } from './invalidation-planner.js';
3
+ import type { ModuleRuntime } from './module-runtime.js';
4
+ import type { RuntimeGenerationModel } from './runtime-generation.js';
5
+ export type CapabilityDelta = ReadonlyMap<FeatureId, ReadonlySet<CapabilityId>>;
6
+ export interface ConventionCapabilityDelta {
7
+ readonly capabilities: CapabilityDelta;
8
+ readonly unresolved: readonly FallbackInvalidation[];
9
+ }
10
+ /**
11
+ * Resolves unowned filesystem paths through mounted Feature conventions.
12
+ * Conventions are asked to enumerate sources only: definition modules stay
13
+ * untouched until SlotGenerationPreparer commits the selected delta.
14
+ */
15
+ export declare class ConventionCapabilityDeltaResolver {
16
+ #private;
17
+ private readonly model;
18
+ private readonly snapshot;
19
+ constructor(modules: ModuleRuntime, model: RuntimeGenerationModel, snapshot: RuntimeSnapshot);
20
+ resolve(fallbacks: readonly FallbackInvalidation[], selected: readonly CapabilityId[]): Promise<ConventionCapabilityDelta>;
21
+ }
22
+ export declare function capabilityDeltaFromSlots(snapshot: RuntimeSnapshot, selected: readonly CapabilityId[]): CapabilityDelta;
23
+ export declare function mergeCapabilityDeltas(...deltas: readonly CapabilityDelta[]): CapabilityDelta;
24
+ export declare function filterCapabilityDelta(delta: CapabilityDelta, predicate: (owner: PluginId) => boolean): CapabilityDelta;
25
+ export declare function capabilityDeltaIds(delta: CapabilityDelta): readonly CapabilityId[];
26
+ export declare function capabilityOwner(id: CapabilityId): PluginId;
@@ -0,0 +1,143 @@
1
+ import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { capabilityId } from '@zhin.js/plugin-runtime';
3
+ import { NodeDiscoveryHost } from './node-discovery-host.js';
4
+ /**
5
+ * Resolves unowned filesystem paths through mounted Feature conventions.
6
+ * Conventions are asked to enumerate sources only: definition modules stay
7
+ * untouched until SlotGenerationPreparer commits the selected delta.
8
+ */
9
+ export class ConventionCapabilityDeltaResolver {
10
+ model;
11
+ snapshot;
12
+ #host;
13
+ constructor(modules, model, snapshot) {
14
+ this.model = model;
15
+ this.snapshot = snapshot;
16
+ this.#host = new NodeDiscoveryHost(modules);
17
+ }
18
+ async resolve(fallbacks, selected) {
19
+ const capabilities = new Map();
20
+ const unresolved = [];
21
+ const cache = new Map();
22
+ for (const fallback of fallbacks) {
23
+ let claimed = false;
24
+ for (const owner of fallback.owners) {
25
+ for (const [feature, provider] of this.model.providers) {
26
+ const roots = (this.model.rootsByFeature.get(feature) ?? [])
27
+ .filter((root) => root.owner === owner && contains(root.packageRoot, fallback.source));
28
+ for (const root of roots) {
29
+ const discovered = await this.#discover(cache, provider, root);
30
+ for (const candidate of discovered) {
31
+ if (candidate.source !== fallback.source)
32
+ continue;
33
+ const ids = capabilities.get(feature) ?? new Set();
34
+ ids.add(candidate.id);
35
+ capabilities.set(feature, ids);
36
+ claimed = true;
37
+ }
38
+ }
39
+ }
40
+ }
41
+ if (!claimed)
42
+ unresolved.push(fallback);
43
+ }
44
+ await this.#discoverMoves(cache, capabilities, selected);
45
+ return Object.freeze({
46
+ capabilities: freezeCapabilityDelta(capabilities),
47
+ unresolved: Object.freeze(unresolved),
48
+ });
49
+ }
50
+ async #discoverMoves(cache, capabilities, selected) {
51
+ for (const id of selected) {
52
+ const slot = this.snapshot.capabilities.get(id);
53
+ if (!slot || slot.origin === 'setup')
54
+ continue;
55
+ const provider = this.model.providers.get(slot.feature);
56
+ if (!provider)
57
+ continue;
58
+ const roots = (this.model.rootsByFeature.get(slot.feature) ?? [])
59
+ .filter((root) => root.owner === slot.owner && contains(root.packageRoot, slot.source));
60
+ for (const root of roots) {
61
+ const discovered = await this.#discover(cache, provider, root);
62
+ if (discovered.some((candidate) => candidate.source === slot.source))
63
+ continue;
64
+ for (const candidate of discovered) {
65
+ if (this.snapshot.capabilities.has(candidate.id))
66
+ continue;
67
+ const ids = capabilities.get(slot.feature) ?? new Set();
68
+ ids.add(candidate.id);
69
+ capabilities.set(slot.feature, ids);
70
+ }
71
+ }
72
+ }
73
+ }
74
+ async #discover(cache, provider, root) {
75
+ const key = `${provider.id}\0${root.owner}\0${root.packageRoot}`;
76
+ const existing = cache.get(key);
77
+ if (existing)
78
+ return existing;
79
+ const discovered = [];
80
+ const context = { ...root, host: this.#host };
81
+ for (const convention of provider.authoring.conventions) {
82
+ for await (const source of convention.discover(context)) {
83
+ discovered.push(Object.freeze({
84
+ source: resolve(source.source),
85
+ id: capabilityId(root.owner, provider.id, source.localName),
86
+ }));
87
+ }
88
+ }
89
+ const frozen = Object.freeze(discovered);
90
+ cache.set(key, frozen);
91
+ return frozen;
92
+ }
93
+ }
94
+ export function capabilityDeltaFromSlots(snapshot, selected) {
95
+ const result = new Map();
96
+ for (const id of selected) {
97
+ const slot = snapshot.capabilities.get(id);
98
+ if (!slot)
99
+ continue;
100
+ const ids = result.get(slot.feature) ?? new Set();
101
+ ids.add(id);
102
+ result.set(slot.feature, ids);
103
+ }
104
+ return freezeCapabilityDelta(result);
105
+ }
106
+ export function mergeCapabilityDeltas(...deltas) {
107
+ const result = new Map();
108
+ for (const delta of deltas) {
109
+ for (const [feature, ids] of delta) {
110
+ const merged = result.get(feature) ?? new Set();
111
+ for (const id of ids)
112
+ merged.add(id);
113
+ result.set(feature, merged);
114
+ }
115
+ }
116
+ return freezeCapabilityDelta(result);
117
+ }
118
+ export function filterCapabilityDelta(delta, predicate) {
119
+ const result = new Map();
120
+ for (const [feature, ids] of delta) {
121
+ for (const id of ids) {
122
+ if (!predicate(capabilityOwner(id)))
123
+ continue;
124
+ const filtered = result.get(feature) ?? new Set();
125
+ filtered.add(id);
126
+ result.set(feature, filtered);
127
+ }
128
+ }
129
+ return freezeCapabilityDelta(result);
130
+ }
131
+ export function capabilityDeltaIds(delta) {
132
+ return Object.freeze([...delta.values()].flatMap((ids) => [...ids]));
133
+ }
134
+ export function capabilityOwner(id) {
135
+ return id.slice(0, id.indexOf('\0'));
136
+ }
137
+ function freezeCapabilityDelta(delta) {
138
+ return new Map([...delta].map(([feature, ids]) => [feature, new Set(ids)]));
139
+ }
140
+ function contains(root, source) {
141
+ const path = relative(root, resolve(source));
142
+ return path === '' || (!path.startsWith('..') && !isAbsolute(path));
143
+ }
@@ -37,6 +37,12 @@ export declare class EnvSchemaParseError extends Error {
37
37
  readonly owner: PluginId;
38
38
  constructor(owner: PluginId, message: string);
39
39
  }
40
+ /**
41
+ * The PluginGraph owner path is the sole namespace for Plugin overlays.
42
+ * Package names and bare instance keys are deliberately not accepted as
43
+ * aliases: a layer for `root/a` can flow only to `root/a` and descendants.
44
+ */
45
+ export declare function environmentOwnerPath(owner: PluginId): readonly PluginId[];
40
46
  export declare function defineEnvSchema<T>(schema: EnvSchema<T>): Readonly<EnvSchema<T>>;
41
47
  export declare function defineEnvironmentLayers(layers?: EnvironmentLayers): Readonly<EnvironmentLayers>;
42
48
  export declare function createEnvStore(owner: PluginId, environment: RuntimeEnvironment, layers?: EnvironmentLayers): EnvStore;
@@ -18,6 +18,19 @@ export class EnvSchemaParseError extends Error {
18
18
  this.name = 'EnvSchemaParseError';
19
19
  }
20
20
  }
21
+ /**
22
+ * The PluginGraph owner path is the sole namespace for Plugin overlays.
23
+ * Package names and bare instance keys are deliberately not accepted as
24
+ * aliases: a layer for `root/a` can flow only to `root/a` and descendants.
25
+ */
26
+ export function environmentOwnerPath(owner) {
27
+ const value = String(owner);
28
+ if (!/^root(?:\/[a-z0-9][a-z0-9-]*)*$/u.test(value)) {
29
+ throw new TypeError(`Invalid Plugin environment owner: ${owner}`);
30
+ }
31
+ const segments = value.split('/');
32
+ return Object.freeze(segments.map((_, index) => segments.slice(0, index + 1).join('/')));
33
+ }
21
34
  export function defineEnvSchema(schema) {
22
35
  const secretKeys = Object.freeze([...(schema.secretKeys ?? [])]);
23
36
  for (const key of secretKeys)
@@ -35,7 +48,10 @@ export function defineEnvironmentLayers(layers = {}) {
35
48
  return [name, copySource(source, `environment ${name}`)];
36
49
  }));
37
50
  const plugins = Object.fromEntries(Object.entries(layers.plugins ?? {}).map(([owner, source]) => {
38
- if (!/^root(?:\/[a-z0-9][a-z0-9-]*)*$/u.test(owner)) {
51
+ try {
52
+ environmentOwnerPath(owner);
53
+ }
54
+ catch {
39
55
  throw new TypeError(`Invalid Plugin environment overlay owner: ${owner}`);
40
56
  }
41
57
  return [owner, copySource(source, `Plugin ${owner}`)];
@@ -61,7 +77,7 @@ export class EnvStoreFactory {
61
77
  const source = {};
62
78
  applyLayer(source, this.#layers.base);
63
79
  applyLayer(source, this.#layers.environments?.[this.#environment.name]);
64
- for (const ancestor of pluginAncestors(owner)) {
80
+ for (const ancestor of environmentOwnerPath(owner)) {
65
81
  applyLayer(source, this.#layers.plugins?.[ancestor]);
66
82
  }
67
83
  return new OwnerEnvStore(owner, this.#environment, Object.freeze(source));
@@ -140,10 +156,6 @@ function applyLayer(target, layer) {
140
156
  target[key] = value;
141
157
  }
142
158
  }
143
- function pluginAncestors(owner) {
144
- const segments = owner.split('/');
145
- return Object.freeze(segments.map((_, index) => segments.slice(0, index + 1).join('/')));
146
- }
147
159
  function assertEnvironmentKey(key) {
148
160
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) {
149
161
  throw new TypeError(`Invalid environment variable name: ${key}`);
@@ -14,6 +14,7 @@ export class HmrCoordinator {
14
14
  if (!this.options.modules.watch) {
15
15
  throw new Error('ModuleRuntime does not provide a file watcher');
16
16
  }
17
+ this.#syncWatchRoots();
17
18
  this.#unwatch = this.options.modules.watch((source) => {
18
19
  void this.enqueue(source).catch(() => undefined);
19
20
  });
@@ -79,6 +80,10 @@ export class HmrCoordinator {
79
80
  if (restart)
80
81
  await this.options.onRestartRequired(restart);
81
82
  else {
83
+ // reload resolves only after RootController has committed the new
84
+ // generation. Read ownership now so failed transactions never make
85
+ // newly discovered workspace packages observable to the watcher.
86
+ this.#syncWatchRoots();
82
87
  const durationMs = Number((performance.now() - startedAt).toFixed(1));
83
88
  await this.options.onReload?.(plan, durationMs);
84
89
  }
@@ -112,4 +117,7 @@ export class HmrCoordinator {
112
117
  for (const waiter of this.#waiters.splice(0))
113
118
  waiter.reject(error);
114
119
  }
120
+ #syncWatchRoots() {
121
+ this.options.modules.updateWatchRoots?.(this.options.ownership().watchRoots());
122
+ }
115
123
  }
@@ -11,10 +11,18 @@ export interface NoInvalidationPlan {
11
11
  export interface GenerationInvalidationPlan {
12
12
  readonly kind: 'generation';
13
13
  readonly changed: readonly string[];
14
+ /** Manifest sources require a graph and process-boundary check before reload. */
15
+ readonly manifestSources: readonly string[];
14
16
  readonly slots: readonly CapabilityId[];
15
17
  readonly subtrees: readonly PluginId[];
18
+ /** Paths without ownership records; RootRuntime may claim them through a convention. */
19
+ readonly fallbacks: readonly FallbackInvalidation[];
16
20
  readonly reasons: readonly string[];
17
21
  }
22
+ export interface FallbackInvalidation {
23
+ readonly source: string;
24
+ readonly owners: readonly PluginId[];
25
+ }
18
26
  export interface ProcessInvalidationPlan {
19
27
  readonly kind: 'process';
20
28
  readonly changed: readonly string[];
@@ -24,9 +24,14 @@ export class InvalidationPlanner {
24
24
  }
25
25
  const slots = new Map();
26
26
  const subtrees = new Set();
27
+ const fallbacks = [];
28
+ const manifestSources = new Set();
27
29
  const reasons = new Set();
28
30
  const processReasons = new Set();
29
31
  for (const source of changed) {
32
+ if (basename(source) === 'package.json') {
33
+ addManifestSource(manifestSources, reasons, source);
34
+ }
30
35
  const affected = unique([source, ...(this.dependencies?.affectedSources(source) ?? [])].map((item) => resolve(item)));
31
36
  let matched = false;
32
37
  for (const item of affected) {
@@ -37,17 +42,25 @@ export class InvalidationPlanner {
37
42
  if (requiresProcessRestart(record)) {
38
43
  processReasons.add(`Root ${record.role} source changed`);
39
44
  }
45
+ else if (record.role === 'manifest') {
46
+ // Manifest changes are handled by RootRuntime after it compares
47
+ // the current and inspected graphs. Do not turn them into a
48
+ // subtree reload here: that would hide concurrent Slot changes.
49
+ addManifestSource(manifestSources, reasons, record.source);
50
+ }
40
51
  else {
41
52
  applyRecord(record, slots, subtrees, reasons);
42
53
  }
43
54
  }
44
55
  }
45
56
  // An untracked support module still belongs to the nearest mounted
46
- // package. Without an owned importer, subtree replacement is safest.
57
+ // package. RootRuntime gets the first chance to claim it through a
58
+ // mounted Feature convention; unresolved paths retain this fallback.
47
59
  if (!matched) {
48
- for (const owner of this.ownership.ownersForPath(source)) {
49
- subtrees.add(owner);
50
- reasons.add(`untracked support source changed in ${owner}`);
60
+ const owners = this.ownership.ownersForPath(source);
61
+ if (owners.length > 0) {
62
+ fallbacks.push(Object.freeze({ source, owners }));
63
+ reasons.add(`untracked source changed in ${owners.join(', ')}`);
51
64
  }
52
65
  }
53
66
  }
@@ -60,7 +73,10 @@ export class InvalidationPlanner {
60
73
  }
61
74
  const roots = collapseSubtrees(subtrees);
62
75
  const retainedSlots = [...slots].flatMap(([capability, owner]) => roots.some((root) => isWithin(owner, root)) ? [] : [capability]);
63
- if (roots.length === 0 && retainedSlots.length === 0) {
76
+ if (roots.length === 0
77
+ && retainedSlots.length === 0
78
+ && manifestSources.size === 0
79
+ && fallbacks.length === 0) {
64
80
  return Object.freeze({
65
81
  kind: 'none',
66
82
  changed,
@@ -70,12 +86,19 @@ export class InvalidationPlanner {
70
86
  return Object.freeze({
71
87
  kind: 'generation',
72
88
  changed,
89
+ manifestSources: Object.freeze([...manifestSources]),
73
90
  slots: Object.freeze(retainedSlots),
74
91
  subtrees: Object.freeze(roots),
92
+ fallbacks: Object.freeze(fallbacks),
75
93
  reasons: Object.freeze([...reasons]),
76
94
  });
77
95
  }
78
96
  }
97
+ function addManifestSource(manifestSources, reasons, source) {
98
+ const manifest = resolve(source);
99
+ manifestSources.add(manifest);
100
+ reasons.add(`Manifest source changed: ${manifest}`);
101
+ }
79
102
  function requiresProcessRestart(record) {
80
103
  return (record.owner === rootPluginId()
81
104
  && (record.role === 'plugin' || record.role === 'schema'));
@@ -1,5 +1,9 @@
1
1
  import type { Dispose } from '@zhin.js/plugin-runtime';
2
2
  import type { ClientModuleRequest } from '@zhin.js/feature-kit';
3
+ export interface ModuleWatchRoot {
4
+ readonly root: string;
5
+ readonly source: 'workspace' | 'local';
6
+ }
3
7
  export interface ModuleRuntime {
4
8
  load<T = unknown>(source: string): Promise<T>;
5
9
  /** Optional compiler/manifest adapter for browser modules such as Page and Layout. */
@@ -8,6 +12,8 @@ export interface ModuleRuntime {
8
12
  affectedSources?(source: string): readonly string[];
9
13
  /** True when this adapter cannot invalidate the complete importer closure safely. */
10
14
  requiresProcessRestart?(source: string): boolean;
15
+ /** Replaces the local package roots after a generation commits. */
16
+ updateWatchRoots?(roots: readonly ModuleWatchRoot[]): void;
11
17
  watch?(listener: (source: string) => void): Dispose;
12
18
  close(): Promise<void>;
13
19
  }
@@ -1,5 +1,5 @@
1
1
  import type { Dispose } from '@zhin.js/plugin-runtime';
2
- import type { ModuleRuntime } from './module-runtime.js';
2
+ import type { ModuleRuntime, ModuleWatchRoot } from './module-runtime.js';
3
3
  export interface NativeDevelopmentModuleRuntimeOptions {
4
4
  readonly projectRoot: string;
5
5
  readonly watch?: boolean;
@@ -15,6 +15,7 @@ export declare class NativeDevelopmentModuleRuntime implements ModuleRuntime {
15
15
  load<T = unknown>(source: string): Promise<T>;
16
16
  invalidate(source: string): void;
17
17
  requiresProcessRestart(source: string): boolean;
18
+ updateWatchRoots(roots: readonly ModuleWatchRoot[]): void;
18
19
  watch(listener: (source: string) => void): Dispose;
19
20
  close(): Promise<void>;
20
21
  }
@@ -20,10 +20,12 @@ export class NativeDevelopmentModuleRuntime {
20
20
  #watchEnabled;
21
21
  #revisions = new Map();
22
22
  #watchers = new Set();
23
+ #watchRoots;
23
24
  #closed = false;
24
25
  constructor(options) {
25
26
  this.#projectRoot = resolve(options.projectRoot);
26
27
  this.#watchEnabled = options.watch ?? true;
28
+ this.#watchRoots = normalizeWatchRoots([this.#projectRoot]);
27
29
  }
28
30
  async load(source) {
29
31
  this.#assertOpen();
@@ -43,9 +45,12 @@ export class NativeDevelopmentModuleRuntime {
43
45
  }
44
46
  requiresProcessRestart(source) {
45
47
  const normalized = resolve(source);
46
- if (!isWithin(this.#projectRoot, normalized))
48
+ const packageRoot = nearestWatchRoot(this.#watchRoots, normalized);
49
+ // Installed packages and external paths are intentionally not watched.
50
+ // The HMR coordinator turns this into a visible process restart reason.
51
+ if (!packageRoot || isNodeModulesSource(packageRoot, normalized))
47
52
  return true;
48
- const parts = relative(this.#projectRoot, normalized).split(sep);
53
+ const parts = relative(packageRoot, normalized).split(sep);
49
54
  const capability = parts.findIndex((part) => capabilityRoots.has(part));
50
55
  if (capability < 0)
51
56
  return isExecutableSource(normalized);
@@ -63,11 +68,23 @@ export class NativeDevelopmentModuleRuntime {
63
68
  // entry's zhin-generation, so the importer closure keeps the old code.
64
69
  return ['.js', '.json', '.ts'].includes(extname(normalized));
65
70
  }
71
+ updateWatchRoots(roots) {
72
+ this.#assertOpen();
73
+ const next = normalizeWatchRoots([
74
+ this.#projectRoot,
75
+ ...roots.map((root) => root.root),
76
+ ]);
77
+ if (sameRoots(this.#watchRoots, next))
78
+ return;
79
+ this.#watchRoots = next;
80
+ for (const watcher of this.#watchers)
81
+ watcher.replaceRoots(next);
82
+ }
66
83
  watch(listener) {
67
84
  this.#assertOpen();
68
85
  if (!this.#watchEnabled)
69
86
  return () => undefined;
70
- const watcher = new PortableSourceWatcher(this.#projectRoot, listener);
87
+ const watcher = new PortableSourceWatcher(this.#watchRoots, listener);
71
88
  this.#watchers.add(watcher);
72
89
  return () => {
73
90
  watcher.close();
@@ -103,48 +120,73 @@ export function assertNativeTypeScriptSupport() {
103
120
  ].join(' '));
104
121
  }
105
122
  class PortableSourceWatcher {
106
- root;
107
123
  listener;
108
- #watcher;
124
+ #watchers = new Set();
109
125
  #pollTimer;
110
126
  #snapshot;
111
127
  #closed = false;
112
- constructor(root, listener) {
113
- this.root = root;
128
+ #roots;
129
+ constructor(roots, listener) {
114
130
  this.listener = listener;
115
- this.#snapshot = sourceSnapshot(root);
116
- this.#startNativeWatcher();
131
+ this.#roots = normalizeWatchRoots(roots);
132
+ this.#snapshot = sourceSnapshot(this.#roots);
133
+ this.#startNativeWatchers();
134
+ }
135
+ replaceRoots(roots) {
136
+ if (this.#closed)
137
+ return;
138
+ const next = normalizeWatchRoots(roots);
139
+ if (sameRoots(this.#roots, next))
140
+ return;
141
+ // The root set and polling snapshot change together. Native handles are
142
+ // replaced afterwards; stale handles are filtered by the committed set.
143
+ this.#roots = next;
144
+ this.#snapshot = sourceSnapshot(next);
145
+ if (!this.#pollTimer)
146
+ this.#startNativeWatchers();
117
147
  }
118
148
  close() {
119
149
  if (this.#closed)
120
150
  return;
121
151
  this.#closed = true;
122
- this.#watcher?.close();
152
+ this.#closeNativeWatchers();
123
153
  if (this.#pollTimer)
124
154
  clearInterval(this.#pollTimer);
125
155
  }
126
- #startNativeWatcher() {
156
+ #startNativeWatchers() {
157
+ if (this.#closed || this.#pollTimer)
158
+ return;
159
+ const next = new Set();
127
160
  try {
128
- this.#watcher = watchDirectory(this.root, { recursive: true }, (_event, name) => {
129
- if (!name)
130
- return;
131
- const source = resolve(this.root, name.toString());
132
- if (isWatchedSource(source) && !isIgnoredSource(this.root, source))
133
- this.listener(source);
134
- });
135
- this.#watcher.on('error', () => this.#startPolling());
161
+ for (const root of this.#roots) {
162
+ const watcher = watchDirectory(root, { recursive: true }, (_event, name) => {
163
+ if (!name || !this.#roots.includes(root))
164
+ return;
165
+ const source = resolve(root, name.toString());
166
+ if (isWatchedSource(source) && !isIgnoredSource(root, source))
167
+ this.listener(source);
168
+ });
169
+ watcher.on('error', () => this.#startPolling());
170
+ next.add(watcher);
171
+ }
136
172
  }
137
173
  catch {
174
+ for (const watcher of next)
175
+ watcher.close();
138
176
  this.#startPolling();
177
+ return;
139
178
  }
179
+ const previous = this.#watchers;
180
+ this.#watchers = next;
181
+ for (const watcher of previous)
182
+ watcher.close();
140
183
  }
141
184
  #startPolling() {
142
185
  if (this.#closed || this.#pollTimer)
143
186
  return;
144
- this.#watcher?.close();
145
- this.#watcher = undefined;
187
+ this.#closeNativeWatchers();
146
188
  this.#pollTimer = setInterval(() => {
147
- const next = sourceSnapshot(this.root);
189
+ const next = sourceSnapshot(this.#roots);
148
190
  const sources = new Set([...this.#snapshot.keys(), ...next.keys()]);
149
191
  for (const source of sources) {
150
192
  if (this.#snapshot.get(source) !== next.get(source))
@@ -153,8 +195,13 @@ class PortableSourceWatcher {
153
195
  this.#snapshot = next;
154
196
  }, 100);
155
197
  }
198
+ #closeNativeWatchers() {
199
+ for (const watcher of this.#watchers)
200
+ watcher.close();
201
+ this.#watchers.clear();
202
+ }
156
203
  }
157
- function sourceSnapshot(root) {
204
+ function sourceSnapshot(roots) {
158
205
  const result = new Map();
159
206
  const visit = (directory) => {
160
207
  let entries;
@@ -179,7 +226,8 @@ function sourceSnapshot(root) {
179
226
  }
180
227
  }
181
228
  };
182
- visit(root);
229
+ for (const root of roots)
230
+ visit(root);
183
231
  return result;
184
232
  }
185
233
  function isWatchedSource(source) {
@@ -212,3 +260,16 @@ function isWithin(root, source) {
212
260
  const child = relative(root, source);
213
261
  return child === '' || (!child.startsWith('..') && !isAbsolute(child));
214
262
  }
263
+ function nearestWatchRoot(roots, source) {
264
+ return roots.find((root) => isWithin(root, source));
265
+ }
266
+ function isNodeModulesSource(root, source) {
267
+ return relative(root, source).split(sep).some((part) => part === 'node_modules');
268
+ }
269
+ function normalizeWatchRoots(roots) {
270
+ const sorted = [...new Set(roots.map((root) => resolve(root)))].sort((left, right) => left.length - right.length);
271
+ return Object.freeze(sorted.filter((root, index) => !sorted.slice(0, index).some((parent) => isWithin(parent, root))));
272
+ }
273
+ function sameRoots(left, right) {
274
+ return left.length === right.length && left.every((root, index) => root === right[index]);
275
+ }
@@ -41,4 +41,10 @@ export declare class PluginScopeAssembler {
41
41
  createdScopeDisposers(): readonly (readonly [PluginId, Dispose])[];
42
42
  generationHandoff(): GenerationHandoff | undefined;
43
43
  setupCapabilities(): readonly Readonly<SetupCapabilityRegistration>[];
44
+ /**
45
+ * Root resources are process-owned. A Plugin Scope receives only a facade
46
+ * that translates logical table/job names to its owner namespace. The root
47
+ * owner deliberately keeps bare names for backward-compatible projects.
48
+ */
49
+ private installOwnerScopedHosts;
44
50
  }
@@ -1,5 +1,5 @@
1
1
  import { resolve } from 'node:path';
2
- import { GenerationHandoffStack, Scope, capabilityId, featureId, rootPluginId, } from '@zhin.js/plugin-runtime';
2
+ import { GenerationHandoffStack, Scope, capabilityId, createPluginDatabaseHost, createPluginScheduleHost, databaseHostToken, databaseRootHostToken, featureId, rootPluginId, scheduleHostToken, scheduleRootHostToken, unwrapPluginDatabaseHost, unwrapPluginScheduleHost, } from '@zhin.js/plugin-runtime';
3
3
  import { runtimeEnvironmentToken } from './environment.js';
4
4
  import { EnvStoreFactory, envStoreToken, } from './environment-store.js';
5
5
  import { createPrimaryConfig, primaryConfigToken, } from './primary-config.js';
@@ -70,6 +70,8 @@ export class PluginScopeAssembler {
70
70
  config,
71
71
  });
72
72
  }
73
+ if (node.parent)
74
+ this.installOwnerScopedHosts(scope, node.id);
73
75
  const config = Object.freeze(this.configResolver(node) ?? {});
74
76
  const view = { get: () => config };
75
77
  const plugin = Object.freeze({
@@ -196,6 +198,29 @@ export class PluginScopeAssembler {
196
198
  setupCapabilities() {
197
199
  return Object.freeze([...this.#setupCapabilities.values()]);
198
200
  }
201
+ /**
202
+ * Root resources are process-owned. A Plugin Scope receives only a facade
203
+ * that translates logical table/job names to its owner namespace. The root
204
+ * owner deliberately keeps bare names for backward-compatible projects.
205
+ */
206
+ installOwnerScopedHosts(scope, owner) {
207
+ const database = scope.has(databaseRootHostToken)
208
+ ? scope.use(databaseRootHostToken)
209
+ : scope.has(databaseHostToken)
210
+ ? unwrapPluginDatabaseHost(scope.use(databaseHostToken))
211
+ : undefined;
212
+ if (database) {
213
+ scope.provide(databaseHostToken, createPluginDatabaseHost(owner, database));
214
+ }
215
+ const schedule = scope.has(scheduleRootHostToken)
216
+ ? scope.use(scheduleRootHostToken)
217
+ : scope.has(scheduleHostToken)
218
+ ? unwrapPluginScheduleHost(scope.use(scheduleHostToken))
219
+ : undefined;
220
+ if (schedule) {
221
+ scope.provide(scheduleHostToken, createPluginScheduleHost(owner, schedule));
222
+ }
223
+ }
199
224
  }
200
225
  function isWithin(plugin, root) {
201
226
  return plugin === root || plugin.startsWith(`${root}/`);
@@ -16,6 +16,7 @@ import { ProjectGraphService, } from './project-graph.js';
16
16
  import { HmrCoordinator } from './hmr-coordinator.js';
17
17
  import { RootProcessRestartExecutor, } from './process-restart.js';
18
18
  import { SlotGenerationPreparer } from './slot-generation-preparer.js';
19
+ import { capabilityDeltaFromSlots, capabilityDeltaIds, ConventionCapabilityDeltaResolver, filterCapabilityDelta, mergeCapabilityDeltas, } from './convention-capability-delta.js';
19
20
  import { SourceOwnershipIndex } from './source-ownership.js';
20
21
  import { addCapabilitySlot, featureSetupAliases, mergeSetupCapabilities, } from './setup-capabilities.js';
21
22
  import { SubtreeGenerationPreparer, SubtreeTopologyChangedError, } from './subtree-generation-preparer.js';
@@ -114,20 +115,24 @@ export class RootRuntime {
114
115
  let prepared;
115
116
  let restart;
116
117
  const snapshot = await this.controller.reload(plan.subtrees[0] ?? plan.slots[0] ?? rootPluginId(), async (current) => {
117
- if (this.#model && this.#isManifestTopologyPlan(plan)) {
118
+ const resolved = await this.#resolveCapabilityDelta(current, plan);
119
+ const effective = resolved.plan;
120
+ if (this.#model && effective.manifestSources.length > 0) {
118
121
  const inspected = await this.#inspectProject();
119
- restart = new RestartBoundaryPlanner().plan(this.#model.graph, inspected.graph, plan.changed);
122
+ restart = new RestartBoundaryPlanner().plan(this.#model.graph, inspected.graph, effective.changed);
120
123
  if (restart)
121
124
  return undefined;
122
- prepared = await new TopologyGenerationPreparer(this.#modules, this.#model, inspected.graph, inspected.configResolver, inspected.primaryConfigDocument, this.#environment, this.#installResources, this.#environmentLayers, this.#isolation).prepare(current);
125
+ prepared = effective.subtrees.includes(rootPluginId())
126
+ ? await this.#prepareInspected(current, inspected)
127
+ : await new TopologyGenerationPreparer(this.#modules, this.#model, inspected.graph, inspected.configResolver, inspected.primaryConfigDocument, this.#environment, this.#installResources, this.#environmentLayers, this.#isolation).prepare(current, { ...effective, capabilities: resolved.capabilities });
123
128
  }
124
- else if (plan.subtrees.length === 0 && plan.slots.length > 0 && this.#model) {
129
+ else if (effective.subtrees.length === 0 && resolved.capabilities.size > 0 && this.#model) {
125
130
  prepared = await new SlotGenerationPreparer(this.#modules, this.#model)
126
- .prepare(current, plan.slots);
131
+ .prepare(current, resolved.capabilities);
127
132
  }
128
- else if (this.#model && this.#canPrepareSubtrees(plan)) {
133
+ else if (this.#model && this.#canPrepareSubtrees(effective)) {
129
134
  const inspected = await this.#inspectProject();
130
- prepared = await this.#prepareSubtrees(current, inspected, plan.subtrees);
135
+ prepared = await this.#prepareSubtrees(current, inspected, effective.subtrees);
131
136
  }
132
137
  else {
133
138
  prepared = await this.#prepare(current);
@@ -140,21 +145,29 @@ export class RootRuntime {
140
145
  this.#accept(prepared);
141
146
  return snapshot;
142
147
  }
143
- #isManifestTopologyPlan(plan) {
144
- let manifest = false;
145
- for (const source of plan.changed) {
146
- const records = this.#ownership.recordsFor(source);
147
- if (records.some((record) => record.role === 'manifest'))
148
- manifest = true;
149
- if (records.some((record) => record.role !== 'manifest'))
150
- return false;
151
- }
152
- return manifest;
153
- }
154
148
  #accept(prepared) {
155
149
  this.#ownership = prepared.ownership;
156
150
  this.#model = prepared.model;
157
151
  }
152
+ async #resolveCapabilityDelta(current, plan) {
153
+ const known = capabilityDeltaFromSlots(current, plan.slots);
154
+ if (!this.#model || (plan.fallbacks.length === 0 && plan.slots.length === 0)) {
155
+ return { plan, capabilities: known };
156
+ }
157
+ const discovered = await new ConventionCapabilityDeltaResolver(this.#modules, this.#model, current).resolve(plan.fallbacks, plan.slots);
158
+ const subtrees = collapseInvalidationSubtrees([
159
+ ...plan.subtrees,
160
+ ...discovered.unresolved.flatMap((fallback) => fallback.owners),
161
+ ]);
162
+ const capabilities = filterCapabilityDelta(mergeCapabilityDeltas(known, discovered.capabilities), (owner) => !subtrees.some((root) => isWithinInvalidationRoot(owner, root)));
163
+ const effective = Object.freeze({
164
+ ...plan,
165
+ slots: capabilityDeltaIds(capabilities),
166
+ subtrees,
167
+ fallbacks: Object.freeze([]),
168
+ });
169
+ return { plan: effective, capabilities };
170
+ }
158
171
  #canPrepareSubtrees(plan) {
159
172
  if (plan.subtrees.length === 0 || plan.subtrees.includes(rootPluginId()))
160
173
  return false;
@@ -299,6 +312,13 @@ export class RootRuntime {
299
312
  }
300
313
  }
301
314
  }
315
+ function collapseInvalidationSubtrees(values) {
316
+ const sorted = [...new Set(values)].sort((left, right) => left.length - right.length);
317
+ return Object.freeze(sorted.filter((candidate, index) => !sorted.slice(0, index).some((root) => isWithinInvalidationRoot(candidate, root))));
318
+ }
319
+ function isWithinInvalidationRoot(plugin, root) {
320
+ return plugin === root || plugin.startsWith(`${root}/`);
321
+ }
302
322
  function withConfigDocumentHandoff(generation, document, committed) {
303
323
  const handoffs = new GenerationHandoffStack();
304
324
  if (generation.handoff)
@@ -1,9 +1,10 @@
1
1
  import { type CapabilityId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
2
  import type { ModuleRuntime } from './module-runtime.js';
3
3
  import type { PreparedRuntimeGeneration, RuntimeGenerationModel } from './runtime-generation.js';
4
+ import { type CapabilityDelta } from './convention-capability-delta.js';
4
5
  export declare class SlotGenerationPreparer {
5
6
  private readonly modules;
6
7
  private readonly model;
7
8
  constructor(modules: ModuleRuntime, model: RuntimeGenerationModel);
8
- prepare(current: RuntimeSnapshot, selected: readonly CapabilityId[]): Promise<PreparedRuntimeGeneration>;
9
+ prepare(current: RuntimeSnapshot, selected: readonly CapabilityId[] | CapabilityDelta): Promise<PreparedRuntimeGeneration>;
9
10
  }
@@ -3,6 +3,7 @@ import { FeatureDiscovery } from '@zhin.js/feature-kit';
3
3
  import { FeatureProjector, composeGenerationHandoffs } from './feature-projector.js';
4
4
  import { NodeDiscoveryHost } from './node-discovery-host.js';
5
5
  import { SourceOwnershipIndex } from './source-ownership.js';
6
+ import { capabilityDeltaFromSlots, } from './convention-capability-delta.js';
6
7
  export class SlotGenerationPreparer {
7
8
  modules;
8
9
  model;
@@ -11,7 +12,9 @@ export class SlotGenerationPreparer {
11
12
  this.model = model;
12
13
  }
13
14
  async prepare(current, selected) {
14
- const selectedByFeature = groupByFeature(current, selected);
15
+ const selectedByFeature = Array.isArray(selected)
16
+ ? capabilityDeltaFromSlots(current, selected)
17
+ : selected;
15
18
  const capabilities = new Map(current.capabilities);
16
19
  const discovery = new FeatureDiscovery(new NodeDiscoveryHost(this.modules));
17
20
  for (const [feature, ids] of selectedByFeature) {
@@ -59,18 +62,6 @@ export class SlotGenerationPreparer {
59
62
  }
60
63
  }
61
64
  }
62
- function groupByFeature(current, selected) {
63
- const result = new Map();
64
- for (const id of selected) {
65
- const slot = current.capabilities.get(id);
66
- if (!slot)
67
- throw new Error(`Cannot reload missing Capability Slot: ${id}`);
68
- const ids = result.get(slot.feature) ?? new Set();
69
- ids.add(id);
70
- result.set(slot.feature, ids);
71
- }
72
- return result;
73
- }
74
65
  async function disposeProjections(disposers, prepareError) {
75
66
  const rollback = new DisposeStack();
76
67
  for (const dispose of disposers)
@@ -1,4 +1,5 @@
1
1
  import type { CapabilityId, FeatureId, PluginId, RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import type { ModuleWatchRoot } from './module-runtime.js';
2
3
  import type { ProjectGraph } from './project-graph.js';
3
4
  export type SourceRole = 'plugin' | 'schema' | 'manifest' | 'feature' | 'capability';
4
5
  export interface SourceOwnershipRecord {
@@ -16,4 +17,6 @@ export declare class SourceOwnershipIndex {
16
17
  addPackageRoot(packageRoot: string, owner: PluginId): void;
17
18
  recordsFor(source: string): readonly SourceOwnershipRecord[];
18
19
  ownersForPath(source: string): readonly PluginId[];
20
+ watchRoots(): readonly ModuleWatchRoot[];
21
+ addWatchRoot(root: ModuleWatchRoot): void;
19
22
  }
@@ -2,11 +2,17 @@ import { isAbsolute, relative, resolve } from 'node:path';
2
2
  export class SourceOwnershipIndex {
3
3
  #records = new Map();
4
4
  #packages = [];
5
+ #watchRoots = new Map();
5
6
  static empty() {
6
7
  return new SourceOwnershipIndex();
7
8
  }
8
9
  static fromGeneration(graph, snapshot, featureIdsByPackageRoot) {
9
10
  const index = new SourceOwnershipIndex();
11
+ for (const pkg of graph.packages.values()) {
12
+ if (pkg.source === 'node_modules')
13
+ continue;
14
+ index.addWatchRoot({ root: pkg.root, source: pkg.source });
15
+ }
10
16
  visitPlugin(graph.root, (node) => {
11
17
  index.addPackageRoot(node.package.root, node.id);
12
18
  index.add({
@@ -86,6 +92,13 @@ export class SourceOwnershipIndex {
86
92
  }
87
93
  return Object.freeze([...owners]);
88
94
  }
95
+ watchRoots() {
96
+ return Object.freeze([...this.#watchRoots.values()]);
97
+ }
98
+ addWatchRoot(root) {
99
+ const normalized = resolve(root.root);
100
+ this.#watchRoots.set(normalized, Object.freeze({ ...root, root: normalized }));
101
+ }
89
102
  }
90
103
  function visitPlugin(node, visit) {
91
104
  visit(node);
@@ -1,4 +1,4 @@
1
- import { type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
1
+ import { type CapabilityId, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
2
  import type { RuntimeEnvironment } from './environment.js';
3
3
  import type { EnvironmentLayers } from './environment-store.js';
4
4
  import type { RuntimeConfigDocument } from './config-composer.js';
@@ -7,6 +7,13 @@ import type { ModuleRuntime } from './module-runtime.js';
7
7
  import { type PluginConfigResolver, type RootResourceInstaller } from './plugin-scope-assembler.js';
8
8
  import type { ProjectGraph } from './project-graph.js';
9
9
  import type { PreparedRuntimeGeneration, RuntimeGenerationModel } from './runtime-generation.js';
10
+ import { type CapabilityDelta } from './convention-capability-delta.js';
11
+ /** Runtime-local invalidation to commit alongside an ABI-safe manifest change. */
12
+ export interface TopologyRuntimeDelta {
13
+ readonly slots: readonly CapabilityId[];
14
+ readonly subtrees: readonly PluginId[];
15
+ readonly capabilities?: CapabilityDelta;
16
+ }
10
17
  /** Prepares manifest topology changes without rebuilding stable Plugin Scopes. */
11
18
  export declare class TopologyGenerationPreparer {
12
19
  #private;
@@ -20,5 +27,5 @@ export declare class TopologyGenerationPreparer {
20
27
  private readonly environmentLayers;
21
28
  private readonly isolation?;
22
29
  constructor(modules: ModuleRuntime, model: RuntimeGenerationModel, graph: ProjectGraph, configResolver: PluginConfigResolver, primaryConfigDocument: RuntimeConfigDocument, environment: RuntimeEnvironment, installResources?: RootResourceInstaller | undefined, environmentLayers?: EnvironmentLayers, isolation?: IsolatedPluginRuntimePort | undefined);
23
- prepare(current: RuntimeSnapshot): Promise<PreparedRuntimeGeneration | undefined>;
30
+ prepare(current: RuntimeSnapshot, delta?: TopologyRuntimeDelta): Promise<PreparedRuntimeGeneration | undefined>;
24
31
  }
@@ -6,6 +6,7 @@ import { FeatureProjector, composeGenerationHandoffs } from './feature-projector
6
6
  import { NodeDiscoveryHost } from './node-discovery-host.js';
7
7
  import { PluginScopeAssembler, } from './plugin-scope-assembler.js';
8
8
  import { SourceOwnershipIndex } from './source-ownership.js';
9
+ import { capabilityDeltaFromSlots, capabilityDeltaIds, } from './convention-capability-delta.js';
9
10
  import { addCapabilitySlot, featureSetupAliases, mergeSetupCapabilities, } from './setup-capabilities.js';
10
11
  import { TopologyTransactionPlanner, collapseRoots, graphNodes, graphOrder, isWithin, } from './topology-transaction.js';
11
12
  /** Prepares manifest topology changes without rebuilding stable Plugin Scopes. */
@@ -30,7 +31,7 @@ export class TopologyGenerationPreparer {
30
31
  this.environmentLayers = environmentLayers;
31
32
  this.isolation = isolation;
32
33
  }
33
- async prepare(current) {
34
+ async prepare(current, delta = { slots: [], subtrees: [] }) {
34
35
  const planned = new TopologyTransactionPlanner().plan(this.model.graph, this.graph);
35
36
  const nextNodes = graphNodes(this.graph);
36
37
  const configReplacements = changedConfigRoots(current, nextNodes, this.configResolver);
@@ -39,7 +40,9 @@ export class TopologyGenerationPreparer {
39
40
  ...configReplacements,
40
41
  ]);
41
42
  const plan = withReplacements(planned, replacedPluginRoots);
42
- if (!plan.changed)
43
+ const subtreeRoots = collapseRoots(delta.subtrees);
44
+ const selected = delta.capabilities ?? capabilityDeltaFromSlots(current, delta.slots);
45
+ if (!plan.changed && subtreeRoots.length === 0 && selected.size === 0)
43
46
  return undefined;
44
47
  const featureTopology = await this.#loadFeatureTopology(plan);
45
48
  const plugins = new PluginScopeAssembler(this.modules, this.configResolver, this.environment, this.primaryConfigDocument, this.installResources, this.environmentLayers, {
@@ -52,10 +55,12 @@ export class TopologyGenerationPreparer {
52
55
  const setupRoots = collapseRoots([
53
56
  ...plan.addedPluginRoots,
54
57
  ...plan.replacedPluginRoots,
58
+ ...subtreeRoots,
55
59
  ]);
56
60
  const removalRoots = collapseRoots([
57
61
  ...plan.removedPluginRoots,
58
62
  ...plan.replacedPluginRoots,
63
+ ...subtreeRoots,
59
64
  ]);
60
65
  plugins.removeSubtrees(removalRoots);
61
66
  const projectionDisposers = new Map();
@@ -69,7 +74,7 @@ export class TopologyGenerationPreparer {
69
74
  // Retained parents still need a new immutable children view after add,
70
75
  // remove, move, or reorder operations.
71
76
  plugins.synchronizeTree(this.graph.root);
72
- const capabilities = await this.#prepareCapabilities(current, plan, setupRoots, featureTopology);
77
+ const capabilities = await this.#prepareCapabilities(current, plan, setupRoots, featureTopology, selected);
73
78
  mergeSetupCapabilities(capabilities, plugins.setupCapabilities(), featureTopology.providers, featureTopology.rootsByFeature);
74
79
  const projected = await new FeatureProjector(featureTopology.providers.values()).project(current.generation + 1, {
75
80
  root: current.root,
@@ -140,7 +145,7 @@ export class TopologyGenerationPreparer {
140
145
  featureIdsByPackageRoot: new Map(featureIdsByPackageRoot),
141
146
  };
142
147
  }
143
- async #prepareCapabilities(current, plan, setupRoots, topology) {
148
+ async #prepareCapabilities(current, plan, setupRoots, topology, selected) {
144
149
  const nextOwners = new Set(graphNodes(this.graph).keys());
145
150
  const mounted = mountedFeatures(topology);
146
151
  const refresh = new Set();
@@ -167,9 +172,13 @@ export class TopologyGenerationPreparer {
167
172
  refresh.add(ownerFeatureKey(mount.owner, previousFeature));
168
173
  }
169
174
  const capabilities = new Map(current.capabilities);
175
+ const selectedIds = new Set(capabilityDeltaIds(selected));
170
176
  for (const [id, slot] of capabilities) {
171
177
  const key = ownerFeatureKey(slot.owner, slot.feature);
172
- if (!nextOwners.has(slot.owner) || !mounted.has(key) || refresh.has(key)) {
178
+ if (!nextOwners.has(slot.owner)
179
+ || !mounted.has(key)
180
+ || refresh.has(key)
181
+ || selectedIds.has(id)) {
173
182
  capabilities.delete(id);
174
183
  }
175
184
  }
@@ -185,6 +194,19 @@ export class TopologyGenerationPreparer {
185
194
  addCapabilitySlot(capabilities, slot);
186
195
  }
187
196
  }
197
+ // A manifest batch can carry a direct capability change. Rediscover the
198
+ // requested Slots after topology refresh so both observations commit as
199
+ // one immutable generation instead of racing two reload transactions.
200
+ const selectedByFeature = selectedSlotsByFeature(current, selected, refresh);
201
+ for (const [feature, ids] of selectedByFeature) {
202
+ const provider = topology.providers.get(feature);
203
+ if (!provider)
204
+ continue;
205
+ const roots = topology.rootsByFeature.get(feature) ?? [];
206
+ for (const slot of await discovery.discover(provider, roots, { capabilities: ids })) {
207
+ addCapabilitySlot(capabilities, slot);
208
+ }
209
+ }
188
210
  return capabilities;
189
211
  }
190
212
  }
@@ -238,6 +260,20 @@ function featureMountsForReload(graph, packageRoots) {
238
260
  function ownerFeatureKey(owner, feature) {
239
261
  return `${owner}\0${feature}`;
240
262
  }
263
+ function selectedSlotsByFeature(current, selected, refreshed) {
264
+ const result = new Map();
265
+ for (const [feature, selectedIds] of selected) {
266
+ for (const id of selectedIds) {
267
+ const slot = current.capabilities.get(id);
268
+ if (slot && refreshed.has(ownerFeatureKey(slot.owner, slot.feature)))
269
+ continue;
270
+ const ids = result.get(feature) ?? new Set();
271
+ ids.add(id);
272
+ result.set(feature, ids);
273
+ }
274
+ }
275
+ return result;
276
+ }
241
277
  function sameValues(left, right) {
242
278
  return left.length === right.length && left.every((value, index) => value === right[index]);
243
279
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/runtime",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Static Plugin graph, generation transaction and HMR Root runtime for Zhin.js",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -18,22 +18,22 @@
18
18
  "dependencies": {
19
19
  "ajv": "8.18.0",
20
20
  "semver": "7.8.5",
21
- "@zhin.js/feature-kit": "1.0.4",
22
- "@zhin.js/plugin-runtime": "1.1.1"
21
+ "@zhin.js/feature-kit": "1.0.5",
22
+ "@zhin.js/plugin-runtime": "1.1.2"
23
23
  },
24
24
  "devDependencies": {
25
- "@types/node": "^26.1.0",
25
+ "@types/node": "^26.1.2",
26
26
  "typescript": "^6.0.3",
27
- "@zhin.js/adapter": "1.1.2",
28
- "@zhin.js/agent-feature": "1.0.4",
29
- "@zhin.js/command": "1.0.4",
30
- "@zhin.js/component": "1.0.4",
31
- "@zhin.js/layout": "1.0.4",
32
- "@zhin.js/mcp-feature": "1.0.4",
33
- "@zhin.js/middleware": "1.0.4",
34
- "@zhin.js/page": "1.0.4",
35
- "@zhin.js/skill": "1.0.4",
36
- "@zhin.js/tool": "1.0.4"
27
+ "@zhin.js/adapter": "1.1.4",
28
+ "@zhin.js/command": "1.0.6",
29
+ "@zhin.js/agent-feature": "1.0.5",
30
+ "@zhin.js/layout": "1.0.5",
31
+ "@zhin.js/component": "1.0.5",
32
+ "@zhin.js/mcp-feature": "1.0.5",
33
+ "@zhin.js/page": "1.0.5",
34
+ "@zhin.js/middleware": "1.0.5",
35
+ "@zhin.js/skill": "1.0.5",
36
+ "@zhin.js/tool": "1.0.5"
37
37
  },
38
38
  "repository": {
39
39
  "type": "git",