Skip to content

Commit bb02d94

Browse files
committed
fix(objectql): key the bound-action collision audit on an unexported symbol, so SchemaRegistry's published face gains no nameable member
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmE7dZP8v5iv3z1dgybAtT
1 parent f679753 commit bb02d94

3 files changed

Lines changed: 37 additions & 16 deletions

File tree

‎packages/objectql/src/bound-action-collision-report.test.ts‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ObjectKernel, type PluginContext } from '@objectstack/core';
2020
import { defineStack } from '@objectstack/spec';
2121
import { ObjectQL } from './engine.js';
2222
import { ObjectQLPlugin } from './plugin.js';
23+
import { FIND_BOUND_ACTION_COLLISIONS, SchemaRegistry } from './registry.js';
2324

2425
/** The package that OWNS the target object, with an action of its own. */
2526
const OWNER = {
@@ -59,14 +60,14 @@ async function engineWith(packages: any[]) {
5960
const servedRename = (engine: ObjectQL): any[] =>
6061
((engine.registry.getObject('bc_org') as any)?.actions ?? []).filter((a: any) => a?.name === 'rename');
6162

62-
describe('SchemaRegistry.findBoundActionCollisions', () => {
63+
describe('the registry\'s bound-action collision audit', () => {
6364
for (const [order, packages] of [
6465
['owner first', [OWNER, TWIN]],
6566
['twin first', [TWIN, OWNER]],
6667
] as const) {
6768
it(`${order}: a different declaration under the object's own action name is one pair, naming both packages`, async () => {
6869
const engine = await engineWith([...packages]);
69-
expect(engine.registry.findBoundActionCollisions()).toEqual([
70+
expect(engine.registry[FIND_BOUND_ACTION_COLLISIONS]()).toEqual([
7071
{ objectName: 'bc_org', action: 'rename', servedPackageId: 'com.test.owner', shadowedPackageId: 'com.test.twin' },
7172
]);
7273
// Reported, not acted on: the object still serves its own declaration.
@@ -76,7 +77,7 @@ describe('SchemaRegistry.findBoundActionCollisions', () => {
7677

7778
it('one declaration carried by two packages is not a collision', async () => {
7879
const engine = await engineWith([OWNER, CARRIER]);
79-
expect(engine.registry.findBoundActionCollisions()).toEqual([]);
80+
expect(engine.registry[FIND_BOUND_ACTION_COLLISIONS]()).toEqual([]);
8081
});
8182

8283
it('a same-stack bound action — merged onto its object and kept top-level — is not a collision', async () => {
@@ -88,24 +89,34 @@ describe('SchemaRegistry.findBoundActionCollisions', () => {
8889
// The JSON round trip is the compiled artifact's shape: two copies, not one reference.
8990
const engine = await engineWith([JSON.parse(JSON.stringify({ ...stack, ...stack.manifest }))]);
9091
expect((engine.registry.getObject('bc_same') as any)?.actions?.map((a: any) => a.name)).toEqual(['same_action']);
91-
expect(engine.registry.findBoundActionCollisions()).toEqual([]);
92+
expect(engine.registry[FIND_BOUND_ACTION_COLLISIONS]()).toEqual([]);
9293
});
9394

9495
it('two packages binding different declarations of one name to a third package\'s object: the first attached is served, the second reported', async () => {
9596
const owner = { ...OWNER, objects: [{ ...OWNER.objects[0], actions: [] }] };
9697
const second = { ...TWIN, id: 'com.test.second', name: 'second', actions: [{ ...TWIN.actions[0], label: 'Rename (second)', target: 'https://example.com/second' }] };
9798
const engine = await engineWith([owner, TWIN, second]);
9899
expect(servedRename(engine).map((a) => a._packageId)).toEqual(['com.test.twin']);
99-
expect(engine.registry.findBoundActionCollisions()).toEqual([
100+
expect(engine.registry[FIND_BOUND_ACTION_COLLISIONS]()).toEqual([
100101
{ objectName: 'bc_org', action: 'rename', servedPackageId: 'com.test.twin', shadowedPackageId: 'com.test.second' },
101102
]);
102103
});
103104

105+
it('is no nameable member of the published face: keyed on a symbol neither package entry exports', async () => {
106+
const entry = await import('./index.js');
107+
const core = await import('./core.js');
108+
expect('FIND_BOUND_ACTION_COLLISIONS' in entry).toBe(false);
109+
expect('FIND_BOUND_ACTION_COLLISIONS' in core).toBe(false);
110+
expect(Object.values(entry)).not.toContain(FIND_BOUND_ACTION_COLLISIONS);
111+
expect(Object.getOwnPropertyNames(SchemaRegistry.prototype)).not.toContain('findBoundActionCollisions');
112+
expect(Object.getOwnPropertySymbols(SchemaRegistry.prototype)).toContain(FIND_BOUND_ACTION_COLLISIONS);
113+
});
114+
104115
it('narrowed to a set of packages, it answers only the pairs naming one of them', async () => {
105116
const engine = await engineWith([OWNER, TWIN, BYSTANDER]);
106-
expect(engine.registry.findBoundActionCollisions(new Set(['com.test.bystander']))).toEqual([]);
107-
expect(engine.registry.findBoundActionCollisions(new Set(['com.test.owner']))).toHaveLength(1);
108-
expect(engine.registry.findBoundActionCollisions(new Set(['com.test.twin']))).toHaveLength(1);
117+
expect(engine.registry[FIND_BOUND_ACTION_COLLISIONS](new Set(['com.test.bystander']))).toEqual([]);
118+
expect(engine.registry[FIND_BOUND_ACTION_COLLISIONS](new Set(['com.test.owner']))).toHaveLength(1);
119+
expect(engine.registry[FIND_BOUND_ACTION_COLLISIONS](new Set(['com.test.twin']))).toHaveLength(1);
109120
});
110121
});
111122

‎packages/objectql/src/plugin.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import {
4646
import { SecurityCatalogNameConflictError, findEnvironmentHeldSecurityCatalogNames } from './registry.js';
4747
// [ADR-0029 D3] The registry's own refusal of a second owner — read, never
4848
// re-derived, when a residual object's name is already owned.
49-
import { ObjectOwnershipConflictError, collectManifestBoundActions, collectManifestObjectNames, describeBoundActionCollision, describeUnboundActionTargets, type BoundActionCollision } from './registry.js';
49+
import { ObjectOwnershipConflictError, collectManifestBoundActions, collectManifestObjectNames, describeBoundActionCollision, describeUnboundActionTargets, FIND_BOUND_ACTION_COLLISIONS, type BoundActionCollision } from './registry.js';
5050

5151
export type { Plugin, PluginContext };
5252

@@ -789,7 +789,7 @@ export class ObjectQLPlugin implements Plugin {
789789
if (this.boundActionTargetsSealed) {
790790
const arrived = new Set(scope.packageIds);
791791
if (residual?.ownerId !== undefined) arrived.add(residual.ownerId);
792-
this.reportBoundActionCollisions(ctx, ql.registry.findBoundActionCollisions(arrived));
792+
this.reportBoundActionCollisions(ctx, ql.registry[FIND_BOUND_ACTION_COLLISIONS](arrived));
793793
}
794794
// Manifests registered AFTER start() (marketplace install / ledger
795795
// rehydrate arrive on `kernel:ready` or an HTTP request) land in the
@@ -946,7 +946,7 @@ export class ObjectQLPlugin implements Plugin {
946946
// the action and both packages — judged here for the same reason, and
947947
// reported, never refused: the object keeps serving its own (rung 1)
948948
// and the boot goes on. Each late artifact is judged as it arrives.
949-
this.reportBoundActionCollisions(ctx, this.ql?.registry.findBoundActionCollisions() ?? []);
949+
this.reportBoundActionCollisions(ctx, this.ql?.registry[FIND_BOUND_ACTION_COLLISIONS]() ?? []);
950950
// #7737 — FIRST, before anything that might read data: bind every
951951
// declared federated object to its remote table now that every
952952
// plugin's `start()` (including the declared-datasource auto-connect
@@ -2887,7 +2887,7 @@ export class ObjectQLPlugin implements Plugin {
28872887

28882888
/**
28892889
* [#24472] One author-visible warning per same-name pair
2890-
* ({@link SchemaRegistry.findBoundActionCollisions}): the object, the action
2890+
* (the registry's audit under {@link FIND_BOUND_ACTION_COLLISIONS}): the object, the action
28912891
* and both packages. `warn`, by the degradation-level question — the object
28922892
* visibly serves one declaration and nothing claimed persisted is lost.
28932893
*/

‎packages/objectql/src/registry.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,19 @@ export function describeUnboundActionTargets(
153153
return err;
154154
}
155155

156+
/**
157+
* [#24472] The key {@link SchemaRegistry}'s bound-action collision audit is
158+
* carried under. A symbol, and ⛔ deliberately NOT re-exported from the package
159+
* entry (`index.ts`, `core.ts`), so the audit is no NAMEABLE member of the
160+
* registry's published face: only the ObjectQL plugin's boot and late-artifact
161+
* reports reach it, importing the key from this module.
162+
*/
163+
export const FIND_BOUND_ACTION_COLLISIONS: unique symbol = Symbol('objectstack.objectql.findBoundActionCollisions');
164+
156165
/**
157166
* [#24472] A bound top-level action that its target object does NOT list,
158167
* because the object already lists a DIFFERENT declaration under the same
159-
* name — see {@link SchemaRegistry.findBoundActionCollisions}.
168+
* name — see {@link FIND_BOUND_ACTION_COLLISIONS}.
160169
*/
161170
export interface BoundActionCollision {
162171
/** The object both declarations are for. */
@@ -3572,7 +3581,7 @@ export class SchemaRegistry {
35723581
* written back from a served read. An action the object already declares
35733582
* keeps the object's declaration, the precedence the router applies (rung 1
35743583
* before rung 2); when the two declarations differ, the ObjectQL plugin
3575-
* reports the pair at boot ({@link findBoundActionCollisions}). A target no
3584+
* reports the pair at boot ({@link FIND_BOUND_ACTION_COLLISIONS}). A target no
35763585
* package registers is refused at boot, not here — see
35773586
* {@link findUnboundActionTargets}.
35783587
*
@@ -3660,9 +3669,10 @@ export class SchemaRegistry {
36603669
* to the pairs naming one of those packages on either side — a late
36613670
* artifact's, so a pair the boot already reported is not reported again.
36623671
*
3663-
* @internal
3672+
* Keyed on {@link FIND_BOUND_ACTION_COLLISIONS}, a symbol the package entry
3673+
* does not export, so the audit adds no nameable member to the published face.
36643674
*/
3665-
findBoundActionCollisions(packageIds?: ReadonlySet<string>): BoundActionCollision[] {
3675+
[FIND_BOUND_ACTION_COLLISIONS](packageIds?: ReadonlySet<string>): BoundActionCollision[] {
36663676
const out: BoundActionCollision[] = [];
36673677
for (const action of this.boundCodeActions()) {
36683678
const objectName = standaloneActionOwnerKey(action);

0 commit comments

Comments
 (0)