From 14489e951b5204c1cd663738ac59c126e9ac4e4c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:10:39 +0800 Subject: [PATCH] feat(studio): remove the "Local / Custom" stopgap scope from the package selector (ADR-0070 D5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-launch cleanup of the final ADR-0070 D5 tail. The package-scope selector no longer offers a synthetic "Local / Custom (this env)" entry — the `package_id = null` / `sys_metadata` orphan bucket introduced as a stopgap in objectui#1946. ADR-0070 makes every runtime-authored item live in a writable base: the kernel rejects orphan creates (`writable_package_required`, framework#2285) and legacy orphans are adopted into a base ("Adopt loose items", framework#2301 / objectui#1983). With no authoring path producing orphans, the bucket has no reason to exist; since the system is pre-launch there are no environments to migrate first. - package-scope.ts: `buildPackageScopeOptions` returns only writable bases; removed `LOCAL_PACKAGE_ID`, `isLocalScope`, `writableBaseOptions` and the now-unused i18n import. - ContextSelectors.tsx: removed the inline `LOCAL_SCOPE_ID` sentinel, `localScopeLabel()`, and the appended Local option. - ResourceListPage.tsx / StudioHomePage.tsx: create-flow and scope filters simplify (active scope is always a real base; the dead `=== LOCAL` branches are gone). - i18n.ts: dropped the now-unused `engine.package.local` strings (en + zh). - package-scope.test.ts: rewritten for the writable-bases-only contract. Read-side `sys_metadata` provenance handling (row classification, editor artifact detection) is unchanged — the kernel keeps `null` as a legacy read tag. typecheck clean; app-shell suite 931 passed. Closes the D5 tail of #2278. Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0070-d5-remove-local-scope.md | 25 ++++++++ .../app-shell/src/layout/ContextSelectors.tsx | 21 ------- .../views/metadata-admin/ResourceListPage.tsx | 19 +++--- .../views/metadata-admin/StudioHomePage.tsx | 5 +- .../src/views/metadata-admin/i18n.ts | 2 - .../metadata-admin/package-scope.test.ts | 50 ++++------------ .../src/views/metadata-admin/package-scope.ts | 58 ++++--------------- 7 files changed, 56 insertions(+), 124 deletions(-) create mode 100644 .changeset/adr-0070-d5-remove-local-scope.md diff --git a/.changeset/adr-0070-d5-remove-local-scope.md b/.changeset/adr-0070-d5-remove-local-scope.md new file mode 100644 index 000000000..582788cfe --- /dev/null +++ b/.changeset/adr-0070-d5-remove-local-scope.md @@ -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). diff --git a/packages/app-shell/src/layout/ContextSelectors.tsx b/packages/app-shell/src/layout/ContextSelectors.tsx index ffcb1a3eb..49e13ec1f 100644 --- a/packages/app-shell/src/layout/ContextSelectors.tsx +++ b/packages/app-shell/src/layout/ContextSelectors.tsx @@ -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'; @@ -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 */ diff --git a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx index 2ad959b90..1f1dba1c1 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx @@ -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; @@ -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}`); @@ -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], ); diff --git a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx index 01601f241..034233a52 100644 --- a/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx +++ b/packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx @@ -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']); @@ -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], diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index bdcbc7463..f985e0e3f 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -212,7 +212,6 @@ const ENGINE_STRINGS_EN: Record = { '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', @@ -922,7 +921,6 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.list.warnCount': '{count} 个警告:', 'engine.list.allSources': '全部来源', 'engine.list.allPackages': '全部软件包', - 'engine.package.local': '本地 / 自定义(本环境)', 'engine.list.packageFilter': '软件包', 'engine.list.source.artifact': '代码包', 'engine.list.source.runtime': '运行时', diff --git a/packages/app-shell/src/views/metadata-admin/package-scope.test.ts b/packages/app-shell/src/views/metadata-admin/package-scope.test.ts index aa84eb008..c6390abe3 100644 --- a/packages/app-shell/src/views/metadata-admin/package-scope.test.ts +++ b/packages/app-shell/src/views/metadata-admin/package-scope.test.ts @@ -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 = [ @@ -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([]); }); }); diff --git a/packages/app-shell/src/views/metadata-admin/package-scope.ts b/packages/app-shell/src/views/metadata-admin/package-scope.ts index 1477dd161..fce84010d 100644 --- a/packages/app-shell/src/views/metadata-admin/package-scope.ts +++ b/packages/app-shell/src/views/metadata-admin/package-scope.ts @@ -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, @@ -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 })); }