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
16 changes: 16 additions & 0 deletions .changeset/31852-one-package-scoped-refusal-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/core': patch
'@objectstack/objectql': patch
'@objectstack/plugin-sharing': patch
---

refactor(core,objectql,plugin-sharing): one `PackageScopedNameRefusedError` class for the 409 a package-scoped by-name read refuses with

Clause-②: yes (widening)
Metadata-protocol: none

- **`@objectstack/core`: `PackageScopedNameRefusedError`** (new export), beside `PackageScopedRefusal` and `packageScopedRefusalMessage`. It carries `code: 'RESOURCE_CONFLICT'`, `status: 409`, `httpStatus: 409` and the resolver's `refusal`, and its message is the refusal's own sentence, which names every candidate.
- **`@objectstack/objectql`** and **`@objectstack/plugin-sharing`** each declared a class of that name before, and an `instanceof` against one never matched the other. Both now re-export core's class under the same name, so every existing import keeps resolving and one `instanceof` matches a refusal from an objectql reader (`SchemaRegistry#getApp` and the other ADR-0150 by-name readers) and from a plugin-sharing door (a declared sharing rule, queue or assignment rule name) alike. objectql's copy already carried `httpStatus`; plugin-sharing's refusal now carries it too, with the same value.
- **`@objectstack/objectql/core`** (the lean entry) now exports `PackageScopedNameRefusedError` too, since the `SchemaRegistry` it exports throws it.

No door changes its answer: each still refuses `409 RESOURCE_CONFLICT` with the same sentence.
102 changes: 94 additions & 8 deletions packages/cli/scripts/check-app-nav-i18n.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
// removed there under #6660, and `nav_sso_providers` — labelled in #6659
// although no composition this gate boots ever merges it — is pinned as the
// converse case. Neither verdict is one this gate could honestly reach.
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
// The exit-code contract for a refusal, from the frame that owns it —
Expand Down Expand Up @@ -231,6 +231,43 @@ export function contributionsForApp(contributions, appName) {
.map((c) => ({ source: c.source, ids: c.idsByApp?.[appName] ?? [] }));
}

/**
* Read one app back from the composition: its merged nav ids, `undefined` when
* it was never registered, or — when the by-name read is REFUSED — a finding
* that names every candidate.
*
* `registry.getApp(name)` with no package is a read by a caller with no package
* (ADR-0150 D3), so a name two packages in this composition both ship is
* refused `409 RESOURCE_CONFLICT` (`PackageScopedNameRefusedError`) rather than
* answered. That is a composition finding — there is no ONE app to judge —
* and it is reported as one, beside the other composition errors. ⛔ Never
* swallowed (it would read as "not registered", whose remedy is the opposite),
* and never thrown out of the gate as a bare stack. Any other error is not a
* refusal and is rethrown untouched.
*
* `isRefusal` is the class test, handed in so `--self-test` can drive this
* without a build.
*/
export function readMergedApp(appName, getApp, isRefusal) {
let mergedApp;
try {
mergedApp = getApp(appName);
} catch (err) {
if (!isRefusal(err)) throw err;
const candidates = (err.refusal?.candidates ?? []).map((c) =>
c?.kind === 'package' ? `package "${c.packageId}"` : `the ${c?.kind ?? 'unknown holder'}`,
);
return {
refused:
`the \`${appName}\` app could not be read back: the composition holds more than one app by that name, ` +
`so a read naming no package is refused (${err.code} ${err.status}) — ${err.message} ` +
`Candidates: ${candidates.join(', ') || 'none named'}. This gate judges ONE merged app per name: ` +
`rename or remove the app in the package that should not hold it.`,
};
}
return { ids: mergedApp ? collectNavIds(mergedApp.navigation) : undefined };
}

/**
* The pass line, as a VALUE so `--self-test` can pin it.
*
Expand Down Expand Up @@ -702,6 +739,51 @@ function selfTest() {
expect('#14857 NEGATIVE CONTROL: the literal-advisory pin can still fail', spellsALiteralCode(controlLiteralAdvisory), 'the predicate no longer sees a spelled-out code');
expect('#14857 NEGATIVE CONTROL: the stale-code pin can still fail', /Exit code 1\b/.test(controlLiteralAdvisory()), 'the stale-spelling predicate no longer sees `Exit code 1`');

// ── A REFUSED app read is a composition finding, never a throw or a miss (#31852) ──
//
// `getApp(name)` naming no package is refused when two packages ship the name
// (ADR-0150 D3). Recorded shape: the refusal class carries `code`, `status`
// and the resolver's `refusal` with every candidate. The local class stands
// in for `PackageScopedNameRefusedError` so this runs without a build.
class RefusedSample extends Error {
code = 'RESOURCE_CONFLICT';
status = 409;
refusal = {
candidates: [
{ kind: 'package', packageId: 'com.acme.alpha' },
{ kind: 'package', packageId: 'com.acme.beta' },
],
};
}
const isRefusedSample = (err) => err instanceof RefusedSample;
const refusedRead = readMergedApp('setup', () => { throw new RefusedSample('The app "setup" is held by 2 packages.'); }, isRefusedSample);
expect('#31852 a refused read is reported as a finding, not thrown', typeof refusedRead.refused === 'string', JSON.stringify(refusedRead));
expect(
'#31852 the finding names the app and every candidate',
refusedRead.refused?.includes('`setup`') && refusedRead.refused.includes('com.acme.alpha') && refusedRead.refused.includes('com.acme.beta'),
String(refusedRead.refused),
);
expect(
'#31852 a refused read never reads as "not registered" (an undefined id list), whose remedy is the opposite',
refusedRead.ids === undefined && !('ids' in refusedRead),
JSON.stringify(refusedRead),
);
const mergedRead = readMergedApp('setup', () => ({ navigation: MERGED_NAV_SAMPLE }), isRefusedSample);
expect('#31852 a served read answers its merged ids', mergedRead.ids?.includes('nav_webhooks') && !mergedRead.refused, JSON.stringify(mergedRead));
expect('#31852 an unregistered app still answers undefined ids', readMergedApp('setup', () => undefined, isRefusedSample).ids === undefined, 'undefined');
let rethrown;
try {
readMergedApp('setup', () => { throw new TypeError('boom'); }, isRefusedSample);
} catch (err) {
rethrown = err;
}
expect('#31852 NEGATIVE CONTROL: an error that is not the refusal is rethrown, never swallowed', rethrown instanceof TypeError, String(rethrown));
expect(
'#31852 the gate hands readMergedApp the refusal CLASS test, not a code match',
/readMergedApp\(app\.name,[^\n]*instanceof PackageScopedNameRefusedError/.test(readFileSync(fileURLToPath(import.meta.url), 'utf8')),
'the boot read no longer passes the class test',
);

if (failures.length) {
console.error(`\ncheck-app-nav-i18n --self-test: ${failures.length} failure(s)\n`);
for (const f of failures) console.error(` ${f}`);
Expand Down Expand Up @@ -803,7 +885,7 @@ checkBuildPrerequisite();
// Boot the composition and read the merged app back.
// ---------------------------------------------------------------------------

const { ObjectQL } = await import('@objectstack/objectql');
const { ObjectQL, PackageScopedNameRefusedError } = await import('@objectstack/objectql');
const { SetupAppTranslations } = await import('@objectstack/platform-objects/apps');

const engine = new ObjectQL();
Expand Down Expand Up @@ -904,12 +986,13 @@ for (const contributor of CONTRIBUTORS) {
* at all" have different remedies, and `collectNavIds(undefined)` answers `[]`
* for both.
*/
const mergedIdsByApp = new Map(
APPS.map((app) => {
const mergedApp = engine.registry.getApp(app.name);
return [app.name, mergedApp ? collectNavIds(mergedApp.navigation) : undefined];
}),
const mergedReads = new Map(
APPS.map((app) => [
app.name,
readMergedApp(app.name, (name) => engine.registry.getApp(name), (err) => err instanceof PackageScopedNameRefusedError),
]),
);
const mergedIdsByApp = new Map(APPS.map((app) => [app.name, mergedReads.get(app.name).ids]));

// The two verdicts are kept apart because their REMEDIES are opposites, and a
// footer that prescribes one for the other is the #5862 defect (a confident
Expand All @@ -928,7 +1011,10 @@ for (const app of APPS) {
`ids to check.`,
);
}
if (mergedIdsByApp.get(app.name) === undefined) {
const refused = mergedReads.get(app.name).refused;
if (refused) {
compositionErrors.push(refused);
} else if (mergedIdsByApp.get(app.name) === undefined) {
compositionErrors.push(
`the \`${app.name}\` app is not registered at all — the composition produced no app to judge. ` +
`Its shell ships in \`${app.shellPackage}\`, which must be a CONTRIBUTORS entry above.`,
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/package-scoped-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { describe, expect, it } from 'vitest';
import {
ENGINE_FUNCTION_TYPE,
OVERRIDABLE_BASELINE_PACKAGES,
PackageScopedNameRefusedError,
RUNTIME_COMPATIBILITY_PACKAGE_ID,
packageScopedHolderSource,
resolvePackageScopedHolders,
Expand Down Expand Up @@ -204,3 +205,19 @@ describe('the compatibility API\'s default package id is a caller with no packag
expect(resolve([['__runtime__', 'r'], [A, 'a']], [])).toMatchObject({ status: 'refused', reason: 'ambiguous-in-environment' });
});
});

describe('the one refusal class every package-scoped by-name door throws (ADR-0150 D5)', () => {
it('carries the 409 RESOURCE_CONFLICT envelope under both status spellings, and the resolver\'s refusal and sentence', () => {
const refusal = resolve([[A, 'a'], [B, 'b']], []);
expect(refusal.status).toBe('refused');
if (refusal.status !== 'refused') return;
const err = new PackageScopedNameRefusedError(refusal);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe('PackageScopedNameRefusedError');
expect(err.code).toBe('RESOURCE_CONFLICT');
expect(err.status).toBe(409);
expect(err.httpStatus).toBe(409);
expect(err.refusal).toBe(refusal);
expect(err.message).toBe(refusal.message);
});
});
31 changes: 31 additions & 0 deletions packages/core/src/package-scoped-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,37 @@ export function packageScopedRefusalMessage(refusal: Omit<PackageScopedRefusal,
}
}

/**
* A by-name read the package-scoped rule refused (ADR-0150 D3): the name means
* more than one item here, or only items the caller may not reach. The message
* is the refusal's own sentence ({@link packageScopedRefusalMessage}), which
* names every candidate.
*
* The ONE class every package-scoped by-name door throws — objectql's readers
* and plugin-sharing's declared-name doors alike re-export this binding, so a
* catcher matches every one of them with a single `instanceof`. It lives here,
* beside the refusal it carries, because this is the lowest package all of
* them read.
*
* The ADR-0112 envelope a wire door answers: `409 RESOURCE_CONFLICT`, the
* standard catalog's member for a name in conflict, which ADR-0150 D5 gives a
* by-name door's ambiguity. ⛔ Never the `500` fallback, and never `404`.
*/
export class PackageScopedNameRefusedError extends Error {
readonly code = 'RESOURCE_CONFLICT';
readonly status = 409;
/** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads. */
readonly httpStatus = 409;
/** The refusal: the reason, the caller's package and every candidate. */
readonly refusal: PackageScopedRefusal;

constructor(refusal: PackageScopedRefusal) {
super(refusal.message);
this.name = 'PackageScopedNameRefusedError';
this.refusal = refusal;
}
}

function refuse(
query: PackageScopedHoldersQuery,
callerPackageId: string | null,
Expand Down
6 changes: 6 additions & 0 deletions packages/objectql/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export {
} from './registry.js';
export type { ObjectContributor, SchemaRegistryOptions } from './registry.js';

// [#31852, ADR-0150 D5] The refusal `SchemaRegistry#getApp` (and every other
// package-scoped by-name read) throws: `@objectstack/core`'s one class, so a
// lean consumer can match it by class rather than by `code` and `status`.
// Core is already this entry's dependency, so it costs the boundary nothing.
export { PackageScopedNameRefusedError } from './package-scoped-read.js';

// [#14553] The navigation-contribution group diagnostic. Belongs on the LEAN
// entry as well as the barrel: it is a pure derivation over authored metadata
// with no imports of its own, so it costs this entry nothing and stays clear of
Expand Down
25 changes: 25 additions & 0 deletions packages/objectql/src/package-scoped-e8-readers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco
import { SchemaRegistry } from './registry.js';
import { MetadataFacade } from './metadata-facade.js';
import { resolveThrownHttpError } from '@objectstack/types';
import { PackageScopedNameRefusedError as CorePackageScopedNameRefusedError } from '@objectstack/core';
import * as barrel from './index.js';
import * as leanEntry from './core.js';
import {
PACKAGE_SCOPED_READ_TYPES,
PackageScopedNameRefusedError,
Expand Down Expand Up @@ -135,6 +138,28 @@ describe('the refusal crosses a wire door as 409 RESOURCE_CONFLICT (ADR-0150 D5)
});
});

describe('the refusal class is @objectstack/core\'s, one binding on every objectql entry (#31852)', () => {
it('package-scoped-read, the barrel and the lean `./core` entry all export core\'s class itself', () => {
expect(PackageScopedNameRefusedError).toBe(CorePackageScopedNameRefusedError);
expect(barrel.PackageScopedNameRefusedError).toBe(CorePackageScopedNameRefusedError);
expect(leanEntry.PackageScopedNameRefusedError).toBe(CorePackageScopedNameRefusedError);
});

it('a lean consumer matches SchemaRegistry#getApp\'s refusal by class, not only by code and status', () => {
ship('app', 'home', A);
ship('app', 'home', B);
let thrown: unknown;
try {
registry.getApp('home');
} catch (err) {
thrown = err;
}
expect(registry).toBeInstanceOf(leanEntry.SchemaRegistry);
expect(thrown).toBeInstanceOf(leanEntry.PackageScopedNameRefusedError);
expect(thrown).toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409, httpStatus: 409 });
});
});

describe('narrowing (1) — an uncovered type two packages hold: the bare read refuses naming both', () => {
for (const type of UNCOVERED) {
it(`${type}: a caller with no package is refused at the protocol read (both faces), the registry and the facade`, async () => {
Expand Down
28 changes: 6 additions & 22 deletions packages/objectql/src/package-scoped-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
* `@objectstack/metadata-core`.
*/

import type { PackageScopedRefusal } from '@objectstack/core';
import { PackageScopedNameRefusedError } from '@objectstack/core';
import {
resolvePackageScopedName,
type PackageScopedQuery,
Expand Down Expand Up @@ -83,28 +83,12 @@ export function isPackageScopedReadType(type: unknown): type is PackageScopedRea
}

/**
* A by-name read the package-scoped resolver refused (ADR-0150 D3): the name
* means more than one item here, or only items the caller may not reach. The
* message is the resolver's own sentence, which names every candidate.
*
* The ADR-0112 envelope a wire door answers: `409 RESOURCE_CONFLICT`, the
* standard catalog's member for a name in conflict, which ADR-0150 D5 gives a
* by-name door's ambiguity. ⛔ Never the `500` fallback, and never `404`.
* The refusal a by-name read throws: `@objectstack/core`'s one class (ADR-0150
* D5, `409 RESOURCE_CONFLICT`), re-exported under its own name so every
* existing import keeps resolving — the same binding plugin-sharing's doors
* throw, so one `instanceof` matches both.
*/
export class PackageScopedNameRefusedError extends Error {
readonly code = 'RESOURCE_CONFLICT';
readonly status = 409;
/** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads. */
readonly httpStatus = 409;
/** The resolver's refusal: the reason, the caller's package and every candidate. */
readonly refusal: PackageScopedRefusal;

constructor(refusal: PackageScopedRefusal) {
super(refusal.message);
this.name = 'PackageScopedNameRefusedError';
this.refusal = refusal;
}
}
export { PackageScopedNameRefusedError };

/**
* Read `(type, name)` for a caller through {@link resolvePackageScopedName}:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { SchemaRegistry } from '@objectstack/objectql';
import { SchemaRegistry, PackageScopedNameRefusedError as ObjectQLPackageScopedNameRefusedError } from '@objectstack/objectql';
import { PackageScopedNameRefusedError as CorePackageScopedNameRefusedError } from '@objectstack/core';
import { createDeclaredSharingRuleSource } from './declared-sharing-rules.js';
import { SharingRuleService, bindDeclaredRuleNameGuard } from './sharing-rule-service.js';
import { OwnershipQueueService } from './ownership-queue-service.js';
Expand Down Expand Up @@ -538,3 +539,35 @@ describe('a queue read from a caller package resolves by D3 (ADR-0150, the queue
expect(await queues.queuesNaming('position', new Set(['gamma_agent']), 'org_a')).toEqual(['support']);
});
});

describe('one refusal class: objectql\'s readers and this plugin\'s doors throw the same class (#31852)', () => {
it('this plugin re-exports @objectstack/core\'s class, the same binding objectql exports', () => {
expect(PackageScopedNameRefusedError).toBe(CorePackageScopedNameRefusedError);
expect(ObjectQLPackageScopedNameRefusedError).toBe(CorePackageScopedNameRefusedError);
});

it('a refusal thrown by objectql\'s by-name reader is instanceof the class this plugin exports', () => {
registry.registerItem<Row>('app', { name: 'home', label: 'alpha home' }, 'name', ALPHA);
registry.registerItem<Row>('app', { name: 'home', label: 'beta home' }, 'name', BETA);
const refused = (() => {
try {
registry.getApp('home');
} catch (err) {
return err;
}
return undefined;
})();
expect(refused).toBeInstanceOf(PackageScopedNameRefusedError);
expect(refused).toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409 });
});

it('a refusal thrown by this plugin\'s by-name door is instanceof the class objectql exports', async () => {
declare('sharing_rule', ALPHA, sharingRule('emea_share', 'alpha_agent'));
declare('sharing_rule', BETA, sharingRule('emea_share', 'beta_agent'));
const source = createDeclaredSharingRuleSource({ engine: { registry } });
const rules = new SharingRuleService({ engine: makeEngine() as any, sharing: {} as any, declaredRules: source, logger: { warn: () => {} } });
const refused = await rules.declaredRuleNamed('emea_share').catch((e: unknown) => e);
expect(refused).toBeInstanceOf(ObjectQLPackageScopedNameRefusedError);
expect(refused).toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409 });
});
});
Loading
Loading