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
25 changes: 25 additions & 0 deletions .changeset/adr-0070-d5-remove-local-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/app-shell': patch
---

feat(studio): remove the "Local / Custom" stopgap scope from the package selector (ADR-0070 D5)

The package-scope selector no longer offers a synthetic "Local / Custom (this
env)" entry (the `package_id = null` / `sys_metadata` orphan bucket from
objectui#1946). That was a deliberate stopgap; ADR-0070 makes every
runtime-authored item live in a writable **base**, the kernel rejects orphan
creates (`writable_package_required`), and legacy orphans are adopted into a
base via "Adopt loose items". With no authoring path producing orphans, the
bucket has no reason to exist.

- `buildPackageScopeOptions` now returns only writable bases (drops the appended
sentinel); `isLocalScope` / `LOCAL_PACKAGE_ID` / `writableBaseOptions` and the
inline `LOCAL_SCOPE_ID` in `ContextSelectors` are removed.
- The create-flow and list/home scope filters simplify accordingly (a real base
is always the active scope; never the null/local sentinel).
- Read-side `sys_metadata` provenance handling (classifying a row as
runtime-authored, artifact detection in the editor) is unchanged — the kernel
still keeps `null` as a legacy read tag.

Closes the D5 tail of #2278 (the migration tooling it depended on already
shipped).
21 changes: 0 additions & 21 deletions packages/app-shell/src/layout/ContextSelectors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,6 @@ import {
import { getIcon } from '../utils/getIcon';
import { resolveI18nLabel } from '../utils';

// Local/Custom scope sentinel — kept inline (not imported from metadata-admin)
// so this layout module never forms an import cycle with the metadata-admin
// views. Mirrors `LOCAL_PACKAGE_ID` in views/metadata-admin/package-scope.ts.
const LOCAL_SCOPE_ID = 'sys_metadata';
function localScopeLabel(): string {
const lang =
(typeof document !== 'undefined' && document.documentElement?.lang) ||
(typeof navigator !== 'undefined' && navigator.language) ||
'';
return /^zh/i.test(lang) ? '本地 / 自定义(本环境)' : 'Local / Custom (this env)';
}

export interface ContextSelectorFilter {
key: string;
op?: 'eq' | 'ne' | 'in' | 'nin';
Expand Down Expand Up @@ -129,15 +117,6 @@ function useSelectorOptions(def: ContextSelectorDef): { options: Option[]; refet
opts.push({ value, label: typeof labelRaw === 'string' && labelRaw ? labelRaw : value });
}
opts.sort((a, b) => a.label.localeCompare(b.label));
// The package-scope selector gets a stable "Local / Custom (this env)"
// entry for this environment's runtime, DB-authored metadata — it is
// never a real package row (`package_id = null` / `sys_metadata`
// provenance) yet must always be selectable so org-authored items are
// discoverable and editable. The metadata list/get API already treats
// `?package=sys_metadata` as exactly this local scope.
if (/package/i.test(endpoint) && !opts.some((o) => o.value === LOCAL_SCOPE_ID)) {
opts.push({ value: LOCAL_SCOPE_ID, label: localScopeLabel() });
}
setOptions(opts);
} catch {
/* offline / unauthorized — render with no options */
Expand Down
19 changes: 9 additions & 10 deletions packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
resolveResourceConfig,
} from './registry';
import { t, tFormat, translateMetadataType, detectLocale } from './i18n';
import { buildPackageScopeOptions, LOCAL_PACKAGE_ID, isLocalScope } from './package-scope';
import { buildPackageScopeOptions } from './package-scope';

export interface MetadataResourceListPageProps {
type?: string;
Expand Down Expand Up @@ -227,13 +227,13 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
// scope); when none exists yet, prompt to create a base first.
const [showCreateBase, setShowCreateBase] = React.useState(false);
const handleCreate = React.useCallback(() => {
const realBases = (projectPackages ?? []).filter((p) => !isLocalScope(p.id));
if (projectPackages !== null && realBases.length === 0) {
const bases = projectPackages ?? [];
if (projectPackages !== null && bases.length === 0) {
setShowCreateBase(true);
return;
}
if (realBases.length > 0 && (!activePackage || isLocalScope(activePackage))) {
navigate(`./new?package=${encodeURIComponent(realBases[0].id)}`);
if (bases.length > 0 && !activePackage) {
navigate(`./new?package=${encodeURIComponent(bases[0].id)}`);
return;
}
navigate(`./new${pkgSuffix}`);
Expand Down Expand Up @@ -292,11 +292,10 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
// 'sys_metadata' sentinel and untagged rows never match.
if (!activePackage) return false;
const pkg = (row.item as any)?._packageId;
const isLocal = !pkg || pkg === LOCAL_PACKAGE_ID;
// Local/Custom scope surfaces this environment's runtime-authored items
// (untagged / `sys_metadata` provenance); a code package shows its own.
if (activePackage === LOCAL_PACKAGE_ID) return isLocal;
return !isLocal && pkg === activePackage;
// Only rows tagged with the active writable base match. Untagged /
// `sys_metadata`-provenance legacy rows have no scope of their own
// (ADR-0070 D5 — the package-less "Local / Custom" scope is removed).
return pkg === activePackage;
}),
[items, activePackage, config],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import {
tFormat,
detectLocale,
} from './i18n';
import { buildPackageScopeOptions, LOCAL_PACKAGE_ID } from './package-scope';
import { buildPackageScopeOptions } from './package-scope';

const HIDDEN_TYPES = new Set(['field', 'package']);

Expand Down Expand Up @@ -181,9 +181,6 @@ export function StudioHomePage() {
entries.filter((e) => {
if (HIDDEN_TYPES.has(e.type)) return false;
if (!activePackage) return false;
// Local/Custom scope: show every runtime-creatable type so the user can
// start authoring any kind of metadata here, even with zero items yet.
if (activePackage === LOCAL_PACKAGE_ID) return e.allowOrgOverride || e.allowRuntimeCreate;
return (packagesByType[e.type] ?? []).includes(activePackage);
}),
[activePackage, entries, packagesByType],
Expand Down
2 changes: 0 additions & 2 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.list.warnCount': '{count} warning(s):',
'engine.list.allSources': 'All sources',
'engine.list.allPackages': 'All packages',
'engine.package.local': 'Local / Custom (this env)',
'engine.package.writableRequired': 'Pick or create a writable base (package) first — this item cannot be authored into a read-only code package.',
'engine.list.packageFilter': 'Package',
'engine.list.source.artifact': 'Artifact',
Expand Down Expand Up @@ -922,7 +921,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.list.warnCount': '{count} 个警告:',
'engine.list.allSources': '全部来源',
'engine.list.allPackages': '全部软件包',
'engine.package.local': '本地 / 自定义(本环境)',
'engine.list.packageFilter': '软件包',
'engine.list.source.artifact': '代码包',
'engine.list.source.runtime': '运行时',
Expand Down
50 changes: 10 additions & 40 deletions packages/app-shell/src/views/metadata-admin/package-scope.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, expect, it } from 'vitest';
import {
buildPackageScopeOptions,
writableBaseOptions,
isLocalScope,
LOCAL_PACKAGE_ID,
} from './package-scope';
import { buildPackageScopeOptions } from './package-scope';

describe('package-scope (ADR-0070)', () => {
const raw = [
Expand All @@ -15,44 +10,19 @@ describe('package-scope (ADR-0070)', () => {
{ manifest: { id: 'app.acme.hr', name: 'HR', scope: 'project' } },
];

it('buildPackageScopeOptions filters system/cloud and appends the Local sentinel last', () => {
it('returns only writable bases, sorted, with system/cloud filtered out', () => {
const ids = buildPackageScopeOptions(raw).map((o) => o.id);
expect(ids).toContain('app.acme.crm');
expect(ids).toContain('app.acme.hr');
expect(ids).not.toContain('platform.core'); // system scope filtered out
expect(ids[ids.length - 1]).toBe(LOCAL_PACKAGE_ID);
expect(ids).toEqual(['app.acme.crm', 'app.acme.hr']); // sorted by name; no system
});

it('writableBaseOptions excludes the Local sentinel AND code/installed packages', () => {
const ids = writableBaseOptions(raw).map((o) => o.id);
expect(ids).toEqual(['app.acme.crm', 'app.acme.hr']); // sorted by name, no Local, no system
expect(ids).not.toContain(LOCAL_PACKAGE_ID);
});

it('writableBaseOptions is empty when only code/installed packages exist', () => {
expect(writableBaseOptions([{ manifest: { id: 'platform.core', scope: 'system' } }])).toEqual([]);
expect(writableBaseOptions(null)).toEqual([]);
it('never offers a package-less "Local / Custom" scope (ADR-0070 D5 stopgap removed)', () => {
const ids = buildPackageScopeOptions(raw).map((o) => o.id);
expect(ids).not.toContain('sys_metadata');
expect(ids.some((id) => !id)).toBe(false);
});

it('isLocalScope treats null / undefined / the sentinel as local, real bases as not', () => {
expect(isLocalScope(null)).toBe(true);
expect(isLocalScope(undefined)).toBe(true);
expect(isLocalScope(LOCAL_PACKAGE_ID)).toBe(true);
expect(isLocalScope('app.acme.crm')).toBe(false);
});
});

describe('package-scope D6 guardrail (ADR-0070 — no package-less default)', () => {
const raw = [
{ manifest: { id: 'app.acme.crm', name: 'CRM', scope: 'project' } },
{ manifest: { id: 'platform.core', name: 'Core', scope: 'system' } },
];
it('never makes the Local sentinel the default scope (a real base sorts first, Local last)', () => {
const opts = buildPackageScopeOptions(raw);
expect(opts[0].id).not.toBe(LOCAL_PACKAGE_ID);
expect(opts[opts.length - 1].id).toBe(LOCAL_PACKAGE_ID);
});
it('the create-scope source (writableBaseOptions) excludes the Local sentinel entirely', () => {
expect(writableBaseOptions(raw).some((o) => o.id === LOCAL_PACKAGE_ID)).toBe(false);
it('is empty when only code/installed packages exist (no orphan fallback)', () => {
expect(buildPackageScopeOptions([{ manifest: { id: 'platform.core', scope: 'system' } }])).toEqual([]);
expect(buildPackageScopeOptions(null)).toEqual([]);
});
});
58 changes: 11 additions & 47 deletions packages/app-shell/src/views/metadata-admin/package-scope.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,18 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { detectLocale, t } from './i18n';

/**
* Sentinel "package" id for this environment's runtime, DB-authored metadata —
* items with no code-package binding (`package_id IS NULL`). The metadata
* list/get API treats `?package=sys_metadata` as exactly that local scope on
* READ, and a WRITE under it persists `package_id = null` (matching the
* server's runtime-only provenance, see framework #2252).
*
* Why this exists: a self-hosted, metadata-customizable environment is
* single-tenant — there is no "org" dimension here; the real axis is
* code-package vs. runtime (DB-authored). Before this scope, the package
* selector only listed code packages, so metadata authored at runtime
* (`package_id = null`) was filtered out of every code-package view and became
* un-navigable (the route redirected to "new"). Surfacing the local scope as a
* first-class, always-present selector entry makes it discoverable and editable.
*/
export const LOCAL_PACKAGE_ID = 'sys_metadata';

const SYSTEM_SCOPES = new Set(['system', 'cloud']);

/**
* Build the Studio package-scope options from the raw `package` metadata list.
* Filters out system/cloud-scoped packages and appends a stable
* "Local / Custom (this environment)" scope so runtime metadata authored here
* is always selectable/visible — even when zero items exist yet.
* Build the Studio package-scope options from the raw `package` metadata list:
* the **writable bases** (project-scoped, DB-backed packages) only — the sole
* valid authoring destinations (ADR-0070 D2). Code/installed (system|cloud)
* packages are filtered out.
*
* There is no package-less "Local / Custom" scope: every runtime-authored item
* lives in a writable base (ADR-0070 D1/D5 — the kernel rejects orphan creates
* with `writable_package_required`, and legacy orphans are adopted into a base),
* so the selector never offers an orphan bucket. The kernel keeps `null` /
* `sys_metadata` provenance only as a read-side rehydration tag for legacy rows.
*/
export function buildPackageScopeOptions(
rawList: unknown[] | null | undefined,
Expand All @@ -46,28 +33,5 @@ export function buildPackageScopeOptions(
})
.filter((p) => p.id && !SYSTEM_SCOPES.has(p.scope));
rows.sort((a, b) => a.name.localeCompare(b.name));
const opts = rows.map((p) => ({ id: p.id, name: p.name }));
// Append (never default) so the existing first-code-package default is
// preserved; the user opts into the local scope explicitly.
return [...opts, { id: LOCAL_PACKAGE_ID, name: t('engine.package.local', detectLocale()) }];
}

/**
* True for the runtime/null "Local / Custom" sentinel scope. Per ADR-0070 D5
* this is a *migration* surface (move loose items into a base), never a valid
* create destination — callers gate "create" on a real writable base.
*/
export function isLocalScope(id: string | null | undefined): boolean {
return !id || id === LOCAL_PACKAGE_ID;
}

/**
* The writable bases (project-scoped DB packages) from the raw package list —
* the only valid authoring destinations (ADR-0070 D2). Excludes code/installed
* (system|cloud) packages AND the Local sentinel.
*/
export function writableBaseOptions(
rawList: unknown[] | null | undefined,
): { id: string; name: string }[] {
return buildPackageScopeOptions(rawList).filter((o) => o.id !== LOCAL_PACKAGE_ID);
return rows.map((p) => ({ id: p.id, name: p.name }));
}
Loading