Skip to content

Commit 17ae00c

Browse files
authored
feat(studio): remove the "Local / Custom" stopgap scope from the package selector (ADR-0070 D5) (#2025)
1 parent 02da91f commit 17ae00c

7 files changed

Lines changed: 56 additions & 124 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
feat(studio): remove the "Local / Custom" stopgap scope from the package selector (ADR-0070 D5)
6+
7+
The package-scope selector no longer offers a synthetic "Local / Custom (this
8+
env)" entry (the `package_id = null` / `sys_metadata` orphan bucket from
9+
objectui#1946). That was a deliberate stopgap; ADR-0070 makes every
10+
runtime-authored item live in a writable **base**, the kernel rejects orphan
11+
creates (`writable_package_required`), and legacy orphans are adopted into a
12+
base via "Adopt loose items". With no authoring path producing orphans, the
13+
bucket has no reason to exist.
14+
15+
- `buildPackageScopeOptions` now returns only writable bases (drops the appended
16+
sentinel); `isLocalScope` / `LOCAL_PACKAGE_ID` / `writableBaseOptions` and the
17+
inline `LOCAL_SCOPE_ID` in `ContextSelectors` are removed.
18+
- The create-flow and list/home scope filters simplify accordingly (a real base
19+
is always the active scope; never the null/local sentinel).
20+
- Read-side `sys_metadata` provenance handling (classifying a row as
21+
runtime-authored, artifact detection in the editor) is unchanged — the kernel
22+
still keeps `null` as a legacy read tag.
23+
24+
Closes the D5 tail of #2278 (the migration tooling it depended on already
25+
shipped).

packages/app-shell/src/layout/ContextSelectors.tsx

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,6 @@ import {
2727
import { getIcon } from '../utils/getIcon';
2828
import { resolveI18nLabel } from '../utils';
2929

30-
// Local/Custom scope sentinel — kept inline (not imported from metadata-admin)
31-
// so this layout module never forms an import cycle with the metadata-admin
32-
// views. Mirrors `LOCAL_PACKAGE_ID` in views/metadata-admin/package-scope.ts.
33-
const LOCAL_SCOPE_ID = 'sys_metadata';
34-
function localScopeLabel(): string {
35-
const lang =
36-
(typeof document !== 'undefined' && document.documentElement?.lang) ||
37-
(typeof navigator !== 'undefined' && navigator.language) ||
38-
'';
39-
return /^zh/i.test(lang) ? '本地 / 自定义(本环境)' : 'Local / Custom (this env)';
40-
}
41-
4230
export interface ContextSelectorFilter {
4331
key: string;
4432
op?: 'eq' | 'ne' | 'in' | 'nin';
@@ -129,15 +117,6 @@ function useSelectorOptions(def: ContextSelectorDef): { options: Option[]; refet
129117
opts.push({ value, label: typeof labelRaw === 'string' && labelRaw ? labelRaw : value });
130118
}
131119
opts.sort((a, b) => a.label.localeCompare(b.label));
132-
// The package-scope selector gets a stable "Local / Custom (this env)"
133-
// entry for this environment's runtime, DB-authored metadata — it is
134-
// never a real package row (`package_id = null` / `sys_metadata`
135-
// provenance) yet must always be selectable so org-authored items are
136-
// discoverable and editable. The metadata list/get API already treats
137-
// `?package=sys_metadata` as exactly this local scope.
138-
if (/package/i.test(endpoint) && !opts.some((o) => o.value === LOCAL_SCOPE_ID)) {
139-
opts.push({ value: LOCAL_SCOPE_ID, label: localScopeLabel() });
140-
}
141120
setOptions(opts);
142121
} catch {
143122
/* offline / unauthorized — render with no options */

packages/app-shell/src/views/metadata-admin/ResourceListPage.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
resolveResourceConfig,
4141
} from './registry';
4242
import { t, tFormat, translateMetadataType, detectLocale } from './i18n';
43-
import { buildPackageScopeOptions, LOCAL_PACKAGE_ID, isLocalScope } from './package-scope';
43+
import { buildPackageScopeOptions } from './package-scope';
4444

4545
export interface MetadataResourceListPageProps {
4646
type?: string;
@@ -227,13 +227,13 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
227227
// scope); when none exists yet, prompt to create a base first.
228228
const [showCreateBase, setShowCreateBase] = React.useState(false);
229229
const handleCreate = React.useCallback(() => {
230-
const realBases = (projectPackages ?? []).filter((p) => !isLocalScope(p.id));
231-
if (projectPackages !== null && realBases.length === 0) {
230+
const bases = projectPackages ?? [];
231+
if (projectPackages !== null && bases.length === 0) {
232232
setShowCreateBase(true);
233233
return;
234234
}
235-
if (realBases.length > 0 && (!activePackage || isLocalScope(activePackage))) {
236-
navigate(`./new?package=${encodeURIComponent(realBases[0].id)}`);
235+
if (bases.length > 0 && !activePackage) {
236+
navigate(`./new?package=${encodeURIComponent(bases[0].id)}`);
237237
return;
238238
}
239239
navigate(`./new${pkgSuffix}`);
@@ -292,11 +292,10 @@ function DefaultMetadataList({ type, appName }: { type: string; appName?: string
292292
// 'sys_metadata' sentinel and untagged rows never match.
293293
if (!activePackage) return false;
294294
const pkg = (row.item as any)?._packageId;
295-
const isLocal = !pkg || pkg === LOCAL_PACKAGE_ID;
296-
// Local/Custom scope surfaces this environment's runtime-authored items
297-
// (untagged / `sys_metadata` provenance); a code package shows its own.
298-
if (activePackage === LOCAL_PACKAGE_ID) return isLocal;
299-
return !isLocal && pkg === activePackage;
295+
// Only rows tagged with the active writable base match. Untagged /
296+
// `sys_metadata`-provenance legacy rows have no scope of their own
297+
// (ADR-0070 D5 — the package-less "Local / Custom" scope is removed).
298+
return pkg === activePackage;
300299
}),
301300
[items, activePackage, config],
302301
);

packages/app-shell/src/views/metadata-admin/StudioHomePage.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ import {
5959
tFormat,
6060
detectLocale,
6161
} from './i18n';
62-
import { buildPackageScopeOptions, LOCAL_PACKAGE_ID } from './package-scope';
62+
import { buildPackageScopeOptions } from './package-scope';
6363

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

@@ -181,9 +181,6 @@ export function StudioHomePage() {
181181
entries.filter((e) => {
182182
if (HIDDEN_TYPES.has(e.type)) return false;
183183
if (!activePackage) return false;
184-
// Local/Custom scope: show every runtime-creatable type so the user can
185-
// start authoring any kind of metadata here, even with zero items yet.
186-
if (activePackage === LOCAL_PACKAGE_ID) return e.allowOrgOverride || e.allowRuntimeCreate;
187184
return (packagesByType[e.type] ?? []).includes(activePackage);
188185
}),
189186
[activePackage, entries, packagesByType],

packages/app-shell/src/views/metadata-admin/i18n.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
212212
'engine.list.warnCount': '{count} warning(s):',
213213
'engine.list.allSources': 'All sources',
214214
'engine.list.allPackages': 'All packages',
215-
'engine.package.local': 'Local / Custom (this env)',
216215
'engine.package.writableRequired': 'Pick or create a writable base (package) first — this item cannot be authored into a read-only code package.',
217216
'engine.list.packageFilter': 'Package',
218217
'engine.list.source.artifact': 'Artifact',
@@ -922,7 +921,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
922921
'engine.list.warnCount': '{count} 个警告:',
923922
'engine.list.allSources': '全部来源',
924923
'engine.list.allPackages': '全部软件包',
925-
'engine.package.local': '本地 / 自定义(本环境)',
926924
'engine.list.packageFilter': '软件包',
927925
'engine.list.source.artifact': '代码包',
928926
'engine.list.source.runtime': '运行时',
Lines changed: 10 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, expect, it } from 'vitest';
4-
import {
5-
buildPackageScopeOptions,
6-
writableBaseOptions,
7-
isLocalScope,
8-
LOCAL_PACKAGE_ID,
9-
} from './package-scope';
4+
import { buildPackageScopeOptions } from './package-scope';
105

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

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

26-
it('writableBaseOptions excludes the Local sentinel AND code/installed packages', () => {
27-
const ids = writableBaseOptions(raw).map((o) => o.id);
28-
expect(ids).toEqual(['app.acme.crm', 'app.acme.hr']); // sorted by name, no Local, no system
29-
expect(ids).not.toContain(LOCAL_PACKAGE_ID);
30-
});
31-
32-
it('writableBaseOptions is empty when only code/installed packages exist', () => {
33-
expect(writableBaseOptions([{ manifest: { id: 'platform.core', scope: 'system' } }])).toEqual([]);
34-
expect(writableBaseOptions(null)).toEqual([]);
18+
it('never offers a package-less "Local / Custom" scope (ADR-0070 D5 stopgap removed)', () => {
19+
const ids = buildPackageScopeOptions(raw).map((o) => o.id);
20+
expect(ids).not.toContain('sys_metadata');
21+
expect(ids.some((id) => !id)).toBe(false);
3522
});
3623

37-
it('isLocalScope treats null / undefined / the sentinel as local, real bases as not', () => {
38-
expect(isLocalScope(null)).toBe(true);
39-
expect(isLocalScope(undefined)).toBe(true);
40-
expect(isLocalScope(LOCAL_PACKAGE_ID)).toBe(true);
41-
expect(isLocalScope('app.acme.crm')).toBe(false);
42-
});
43-
});
44-
45-
describe('package-scope D6 guardrail (ADR-0070 — no package-less default)', () => {
46-
const raw = [
47-
{ manifest: { id: 'app.acme.crm', name: 'CRM', scope: 'project' } },
48-
{ manifest: { id: 'platform.core', name: 'Core', scope: 'system' } },
49-
];
50-
it('never makes the Local sentinel the default scope (a real base sorts first, Local last)', () => {
51-
const opts = buildPackageScopeOptions(raw);
52-
expect(opts[0].id).not.toBe(LOCAL_PACKAGE_ID);
53-
expect(opts[opts.length - 1].id).toBe(LOCAL_PACKAGE_ID);
54-
});
55-
it('the create-scope source (writableBaseOptions) excludes the Local sentinel entirely', () => {
56-
expect(writableBaseOptions(raw).some((o) => o.id === LOCAL_PACKAGE_ID)).toBe(false);
24+
it('is empty when only code/installed packages exist (no orphan fallback)', () => {
25+
expect(buildPackageScopeOptions([{ manifest: { id: 'platform.core', scope: 'system' } }])).toEqual([]);
26+
expect(buildPackageScopeOptions(null)).toEqual([]);
5727
});
5828
});
Lines changed: 11 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,18 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { detectLocale, t } from './i18n';
4-
5-
/**
6-
* Sentinel "package" id for this environment's runtime, DB-authored metadata —
7-
* items with no code-package binding (`package_id IS NULL`). The metadata
8-
* list/get API treats `?package=sys_metadata` as exactly that local scope on
9-
* READ, and a WRITE under it persists `package_id = null` (matching the
10-
* server's runtime-only provenance, see framework #2252).
11-
*
12-
* Why this exists: a self-hosted, metadata-customizable environment is
13-
* single-tenant — there is no "org" dimension here; the real axis is
14-
* code-package vs. runtime (DB-authored). Before this scope, the package
15-
* selector only listed code packages, so metadata authored at runtime
16-
* (`package_id = null`) was filtered out of every code-package view and became
17-
* un-navigable (the route redirected to "new"). Surfacing the local scope as a
18-
* first-class, always-present selector entry makes it discoverable and editable.
19-
*/
20-
export const LOCAL_PACKAGE_ID = 'sys_metadata';
21-
223
const SYSTEM_SCOPES = new Set(['system', 'cloud']);
234

245
/**
25-
* Build the Studio package-scope options from the raw `package` metadata list.
26-
* Filters out system/cloud-scoped packages and appends a stable
27-
* "Local / Custom (this environment)" scope so runtime metadata authored here
28-
* is always selectable/visible — even when zero items exist yet.
6+
* Build the Studio package-scope options from the raw `package` metadata list:
7+
* the **writable bases** (project-scoped, DB-backed packages) only — the sole
8+
* valid authoring destinations (ADR-0070 D2). Code/installed (system|cloud)
9+
* packages are filtered out.
10+
*
11+
* There is no package-less "Local / Custom" scope: every runtime-authored item
12+
* lives in a writable base (ADR-0070 D1/D5 — the kernel rejects orphan creates
13+
* with `writable_package_required`, and legacy orphans are adopted into a base),
14+
* so the selector never offers an orphan bucket. The kernel keeps `null` /
15+
* `sys_metadata` provenance only as a read-side rehydration tag for legacy rows.
2916
*/
3017
export function buildPackageScopeOptions(
3118
rawList: unknown[] | null | undefined,
@@ -46,28 +33,5 @@ export function buildPackageScopeOptions(
4633
})
4734
.filter((p) => p.id && !SYSTEM_SCOPES.has(p.scope));
4835
rows.sort((a, b) => a.name.localeCompare(b.name));
49-
const opts = rows.map((p) => ({ id: p.id, name: p.name }));
50-
// Append (never default) so the existing first-code-package default is
51-
// preserved; the user opts into the local scope explicitly.
52-
return [...opts, { id: LOCAL_PACKAGE_ID, name: t('engine.package.local', detectLocale()) }];
53-
}
54-
55-
/**
56-
* True for the runtime/null "Local / Custom" sentinel scope. Per ADR-0070 D5
57-
* this is a *migration* surface (move loose items into a base), never a valid
58-
* create destination — callers gate "create" on a real writable base.
59-
*/
60-
export function isLocalScope(id: string | null | undefined): boolean {
61-
return !id || id === LOCAL_PACKAGE_ID;
62-
}
63-
64-
/**
65-
* The writable bases (project-scoped DB packages) from the raw package list —
66-
* the only valid authoring destinations (ADR-0070 D2). Excludes code/installed
67-
* (system|cloud) packages AND the Local sentinel.
68-
*/
69-
export function writableBaseOptions(
70-
rawList: unknown[] | null | undefined,
71-
): { id: string; name: string }[] {
72-
return buildPackageScopeOptions(rawList).filter((o) => o.id !== LOCAL_PACKAGE_ID);
36+
return rows.map((p) => ({ id: p.id, name: p.name }));
7337
}

0 commit comments

Comments
 (0)