Skip to content

Commit b16ee8b

Browse files
feat(spec): a record page can name the apps whose routes open it (PageSchema.apps) (#29437)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi95mUhXWSnPGxpNDeSMsZmpe)_ Fixes #28979 Clause-②: yes (widening) Metadata-protocol: added ## 维护者速读 - **改了什么**:记录页新增可选键 `apps: string[]`。在列出的应用里,该对象的记录视图打开这张页;其它地方(含 Setup)仍打开唯一的 `isDefault` 默认页。同一对象在同一应用里只能有一张:第二张在 defineStack / composeStacks、包注册、元数据保存三道门被拒,点名两张页。 - **为什么改**:裁决 A(#28979,记录 6573557633)。duly 这类应用无法让自己的 `sys_user` 记录页在自己的路由下打开,而改成全局默认会让 Setup → Users 丢掉管理页。 - **风险与回滚**:纯新增键,无旧输入被拒;默认页规则不变(无 `apps` 时行为完全不变)。三道门共用一个读法 `defaultRecordPageObject(page, app)`,错误码沿用 `DEFAULT_RECORD_PAGE_CONFLICT`,无新公开名。回滚即 revert 本 PR。控制台尚未读 `apps`(ui 后续卡),所以合入后界面暂无变化。 - **先看**:`packages/spec/src/ui/default-record-page.ts`(唯一读法)、`packages/objectql/src/registry.ts`(注册门按"槽位"计账,默认槽键不变)、`packages/metadata-protocol/src/protocol-save-door-refusals.ts`(保存门逐槽判)。 - **两分钟验证**:`pnpm --filter @objectstack/spec exec vitest run src/stack-app-record-page.test.ts`,应见 27 个用例全绿(在 main 上 22 红)。 - **需要你做的**:无,席位落地(落地前需一次契约复审档复核)。 ## What changes **The key.** `PageSchema` gains an optional `apps: string[]` (app names: lowercase snake_case, at least one, each once; a repeat is refused at the repeat). On a record page bound to an `object`, it names the apps whose routes open this page for that object (`/apps/APP/OBJECT/record/ID`). Everywhere else, Setup included, the object's one `isDefault` record page still opens. Ruling A on #23015 (one default per object per deployment) is unchanged and no precedence ladder is added: the deployment default and an app's page are two slots, and one page may hold both. The describe carries `[PLANNED — not yet rendered]` until the console reads it. **Placement, the way `isDefault` is gated.** `apps` is refused on any page that is not a record page bound to an object. The one sentence is `misplacedDefaultPageRefusal`'s (it now covers `apps` after `isDefault`), so the page parse (`checkPageDefaultPlacement`, which now reports each key at its own path) and the registry's package door print the same refusal. **One reading, no new public name.** `defaultRecordPageObject(page, app?)` gains an optional second argument: with an app, it answers the object a record page is the page of inside that app (`apps` lists it). With one argument it is byte-for-byte the old reading. Every door asks it once with no app and once per string entry of the page's `apps`. **The three refusal doors, one level down.** Each door keeps its existing mechanism and keys it by slot (an object's default, or an object inside one app) instead of by object alone: - `defineStack` / `composeStacks` (`stack.zod.ts`, `duplicateDefaultRecordPageFindings`): `Object 'X' has two record pages for app 'A' in pages: … Remove 'A' from apps on all but one of them.` (`STACK_CROSS_REFERENCE_INVALID`). - Package registration (`objectql` `registry.ts` + `default-record-page.ts`): the claims map, the holders reading, the package door and the item seam are keyed by `recordPageSlotKey` (the object alone for the default, so default keys are unchanged). `DefaultRecordPageConflictError` keeps its code (`DEFAULT_RECORD_PAGE_CONFLICT`, 422); a conflict carries `app` when the slot is an app's, and the message names the app with its own remedy. `defaultRecordPageHoldersOf(object, app?)` answers an app's slot. - Metadata save (`metadata-protocol` `defaultRecordPageSaveRefusal`): judges each slot the saved page holds against the stored rows and the registry's holders; the refusal names both pages and the app and carries `app`. Saving a page under its own name stays an overlay, as for the default. ## Why An app could not make its own record page over a platform object the page its routes open: plugin-auth's `sys_user_detail` holds `sys_user`'s one default, a second default is refused, and making the app's page the default would take Setup → Users' admin page away from the whole deployment (#28979). Ruling A (director 6573557633, maintainer 「同意」) chose a page-level app scope. ## Tests - `packages/spec/src/stack-app-record-page.test.ts` (new, 27): the key (accepted alone and beside `isDefault`; empty, repeated and non-snake entries refused; refused on a home page, an app page and an unbound record page in the registry door's sentence; both misplaced keys reported); `defaultRecordPageObject(page, app)`; `defineStack` (same app refused naming both; different apps accepted; a page scoped to two apps; default + app page coexist; one page both default and scoped; a second default still reported as a default); `composeStacks` (merged and `preserve`, with controls). - `packages/objectql/src/registry-app-record-page.test.ts` (new, 10): package door across packages and within one manifest, two-app page, install claim and uninstall release, item seam, environment holder, default and app page side by side, second default still the default's refusal, misplaced `apps` at the package door; each refusal with a control. - `packages/objectql/src/default-record-page-save-door.test.ts` (+6 per topology, 12): refused over a shipped page and over a stored page for the same app, controls for another app, beside the package default and an overlay by own name, and `apps` on a home page refused by the parse. - **Forced test edits** (named): `packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts` (`page` serves 27 → 28 top-level properties: `apps`); `packages/spec/src/system/metadata-form-zod-reconciliation.test.ts` (a `planned` omit row for `page.apps`, the `record` / `print` precedent). ## Acceptance notes - **Follow-ups (not in this PR):** ui: `usePageAssignment` / `appObjectScope.declaredRecordPage` read the route's app first, then the default, then declaration order, with the ruling's two pins (`/apps/THAT_APP/sys_user/record/ID` opens the scoped page; the Setup app's route still opens `sys_user_detail`); the liveness row for `page.apps` flips to `live` there. duly#113: delete the URL-action stopgap once the ui half ships. - `apps` entries are app **names** (`AppSchema.name`). The Setup app's name is `setup`; `com.objectstack.setup` is its package id. The ui card maps the route's app segment to the app name. - No cross-reference that a named app exists: a page may scope itself to another package's app, which a one-stack check cannot see. Noted, not filed. - Generated: `authorable-surface/ui.json`, `content/docs/references/ui/page.mdx`, `liveness/state-counts/page.md`; ledgers: `dropped-refinements.baseline.json` (+5 sites for the array refine, 988 → 993), `liveness/page.json` (`apps`: `planned`). ## Verification <details><summary>Red on main, green after</summary> The six implementation files plus the two ledgers restored to main (`2f7977cd`), proven by blob hash; spec and metadata-protocol rebuilt; restored afterwards with an empty `git diff HEAD`. - spec `src/stack-app-record-page.test.ts`: main `Tests 22 failed | 5 passed (27)`; branch `27 passed (27)`. - objectql `registry-app-record-page.test.ts` + `default-record-page-save-door.test.ts`: main `Tests 20 failed | 38 passed (58)`; branch `58 passed`. </details> <details><summary>Package suites</summary> All on the merged head `0b4d7827` (main `dfbe34d8` merged with `os-regen-merge.sh`; the authorable surface regenerated on the merged tree; branch delta vs main unchanged, 17 files), each under `os-verify-lock`, `VERDICT command-exit 0`: - `pnpm --filter @objectstack/spec test`: `Test Files 792 passed (792)`, `Tests 23478 passed | 1 todo`. `typecheck`: exit 0. `check:generated`: exit 0. - `pnpm --filter @objectstack/metadata-protocol test`: `Test Files 304 passed | 4 skipped (308)`, `Tests 22155 passed | 26 skipped`. `typecheck`: exit 0. - `@objectstack/objectql`: the four default-record-page files `Tests 93 passed (93)`; `typecheck`: exit 0. The full suite ran on the pre-merge head `4d11203c`: `Test Files 531 passed (531)`, `Tests 10300 passed` (the merge moved no objectql file). - Other readers of `defaultRecordPageObject` (core `stack-organization-record-page.ts`, service-print, plugin-auth's test) call it with one argument, which reads exactly as before; they compile in the workspace build (`turbo run build --filter=./packages/**`, exit 0). - Dogfood: n/a, no `!` in the title and no `(narrowing)` arm. </details> <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `0b4d7827`: 110 families, all run after the last commit, after a workspace build. - 110 exit 0 (each command with stdin closed). - `dispatch-gates --ran`: `110 derived famil(ies) accounted for — 110 run, 0 NOT-MEASURED (a DERIVED zero)`. - The same 110 also ran on the pre-merge head `4d11203c`: 110 exit 0. - `check-changeset-no-major.mjs --base origin/main --event` with this body's two lines: `LEVEL AXIS: … clause-② yes (widening) and metadata-protocol added`, exit 0. - Local-only: `check:skill-frame-freshness` exit 0; `check:dev-prereqs` exit 1 on machine state (35 `ui/` packages have no dist in this container), not on this diff. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` (repo-wide, CI-owned) not run locally. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmSGMLCTCPxZfV3GqHr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e832ddb commit b16ee8b

17 files changed

Lines changed: 942 additions & 133 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/metadata-protocol': minor
5+
---
6+
7+
feat(spec): a record page can name the apps whose routes open it for its object (`PageSchema.apps`)
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: added
11+
12+
An app could not make its own record page over a platform object the page its routes open: the deployment's one `isDefault` record page (for `sys_user`, plugin-auth's `sys_user_detail`) opened everywhere, and declaring a second default is refused. A record page now takes an optional app scope:
13+
14+
```ts
15+
definePage({
16+
name: 'duly_member',
17+
label: 'Member',
18+
type: 'record',
19+
object: 'sys_user',
20+
apps: ['duly'],
21+
regions: [/* … */],
22+
});
23+
```
24+
25+
- **What it means.** Inside an app `apps` names, the object's record view (`/apps/APP/OBJECT/record/ID`) opens this page. Everywhere else, Setup included, the object's one `isDefault` record page still opens. One default per object per deployment is unchanged, and there is no precedence ladder: the default and an app's page are two slots, and one page may hold both.
26+
- **One page per object per app.** Two record pages for the same object that both name the same app are refused at the doors that refuse a second default, naming both pages and the app: `defineStack` (and so `os validate` / `os build`) and `composeStacks` with `STACK_CROSS_REFERENCE_INVALID`, and package registration and the metadata save door with `DEFAULT_RECORD_PAGE_CONFLICT` (422). Saving a page under its own name stays an overlay of that page, as for the default.
27+
- **Where it is accepted.** Only on a record page bound to an `object`, where `isDefault` is: on any other page `apps` is refused, by the page parse and at package registration, in one sentence. Entries are app names (lowercase snake_case), at least one, each once.
28+
- **Declared ahead of the console.** The console's page assignment reads the route's app in a follow-up; until then every app still opens the default record page, exactly as before the key existed.
29+
- **Readers.** `defaultRecordPageObject(page, app)` (`@objectstack/spec/ui`) answers the object a page is the record page of inside `app`; `SchemaRegistry.defaultRecordPageHoldersOf(object, app)` answers the holders of that slot; a `DefaultRecordPageConflictError` conflict and the save door's refusal carry `app` when the slot is an app's.

‎content/docs/references/ui/page.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ An any-of group: `{ or: [rule, ...] }` matches a record when ANY of its rules ma
227227
| **template** | `Enum<'default' \| 'header-sidebar-main' \| 'three-column' \| 'dashboard'>` | optional (default: `"default"`) | Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place, or a second region filling the same slot, is refused at the region's name. |
228228
| **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. |
229229
| **isDefault** | `boolean` | optional (default: `false`) | Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default's own name is that page, not a second default. Refused on any other page — a page that is not a record page bound to an object: nothing reads it there. An app opens on its first navigation item, and the root landing follows the app's own isDefault. |
230+
| **apps** | `string[]` | optional | [PLANNED — not yet rendered] The apps whose routes open this record page for its `object`: inside an app named here, the object's record view (`/apps/<app>/<object>/record/<id>`) opens this page; everywhere else, Setup included, the object's one `isDefault` record page still opens, so the default and an app's page coexist. App names, non-empty and unique. At most one record page per object per app: a second page naming the same app for the same object is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Refused on any page that is not a record page bound to an object. Until the console reads it, every app opens the default record page. |
230231
| **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (named in a position's `permissionSets`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
231232
| **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: (object \| object)[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) |
232233
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |

‎packages/metadata-protocol/src/protocol-save-door-refusals.ts‎

Lines changed: 79 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -204,13 +204,21 @@ export async function defaultRecordPageSaveRefusal(
204204
verb: 'saved' | 'published' | 'restored' = 'saved',
205205
): Promise<(Error & { code: 'DEFAULT_RECORD_PAGE_CONFLICT'; status: 422 }) | undefined> {
206206
if (type !== 'page') return undefined;
207+
// Every slot the page holds: its object's default (no app), and the record
208+
// page of its object inside each app its `apps` names (ruling A on
209+
// objectstack#28979) — each judged by the one reading, `defaultRecordPageObject`.
210+
const slots: Array<{ object: string; app?: string }> = [];
207211
const object = defaultRecordPageObject(item);
208-
if (object === undefined) return undefined;
212+
if (object !== undefined) slots.push({ object });
213+
const apps = (item as { apps?: unknown } | null | undefined)?.apps;
214+
if (Array.isArray(apps)) {
215+
for (const app of new Set(apps.filter((a): a is string => typeof a === 'string'))) {
216+
const scoped = defaultRecordPageObject(item, app);
217+
if (scoped !== undefined) slots.push({ object: scoped, app });
218+
}
219+
}
220+
if (slots.length === 0) return undefined;
209221

210-
// `shipped`: a page a package ships through the registry, which no save
211-
// can edit; a stored row (bound to a package or not) is editable here.
212-
type Holder = { page: string; packageId?: string; shipped: boolean };
213-
const holders: Holder[] = [];
214222
let records: any[] = [];
215223
try {
216224
// [ADR-0131 D6] A save lands environment-wide, so the pages it meets
@@ -221,52 +229,75 @@ export async function defaultRecordPageSaveRefusal(
221229
}
222230
const stored = this.storedOverlayEntries({ type }, records);
223231
const storedNames = new Set(stored.map((entry) => entry.name));
224-
for (const entry of stored) {
225-
if (entry.name === saveName || defaultRecordPageObject(entry.data) !== object) continue;
226-
holders.push(entry.packageId
227-
? { page: entry.name, packageId: entry.packageId, shipped: false }
228-
: { page: entry.name, shipped: false });
229-
}
230232
const registry = this.engine.registry;
231-
if (typeof registry?.defaultRecordPageHoldersOf === 'function') {
232-
const registered = registry.defaultRecordPageHoldersOf(object) as Array<
233-
{ kind: 'package'; packageId: string; page: string } | { kind: 'environment'; page: string }
234-
>;
235-
for (const holder of registered) {
236-
if (holder.page === saveName || storedNames.has(holder.page)) continue;
237-
holders.push(holder.kind === 'package'
238-
? { page: holder.page, packageId: holder.packageId, shipped: true }
239-
: { page: holder.page, shipped: false });
233+
234+
// `shipped`: a page a package ships through the registry, which no save
235+
// can edit; a stored row (bound to a package or not) is editable here.
236+
type Holder = { page: string; packageId?: string; shipped: boolean };
237+
for (const { object, app } of slots) {
238+
const holders: Holder[] = [];
239+
for (const entry of stored) {
240+
if (entry.name === saveName || defaultRecordPageObject(entry.data, app) !== object) continue;
241+
holders.push(entry.packageId
242+
? { page: entry.name, packageId: entry.packageId, shipped: false }
243+
: { page: entry.name, shipped: false });
240244
}
245+
if (typeof registry?.defaultRecordPageHoldersOf === 'function') {
246+
const registered = (app === undefined
247+
? registry.defaultRecordPageHoldersOf(object)
248+
: registry.defaultRecordPageHoldersOf(object, app)) as Array<
249+
{ kind: 'package'; packageId: string; page: string } | { kind: 'environment'; page: string }
250+
>;
251+
for (const holder of registered) {
252+
if (holder.page === saveName || storedNames.has(holder.page)) continue;
253+
holders.push(holder.kind === 'package'
254+
? { page: holder.page, packageId: holder.packageId, shipped: true }
255+
: { page: holder.page, shipped: false });
256+
}
257+
}
258+
if (holders.length === 0) continue;
259+
260+
const seen = new Set<string>();
261+
const unique = holders.filter((h) => {
262+
const key = `${h.packageId ?? ''}|${h.page}`;
263+
if (seen.has(key)) return false;
264+
seen.add(key);
265+
return true;
266+
});
267+
const describe = (h: Holder) =>
268+
h.packageId ? `page '${h.page}' of package '${h.packageId}'` : `page '${h.page}', authored in this system`;
269+
const named = unique.map(describe);
270+
const others = named.length === 1 ? `${named[0]} already is` : `${named.join(' and ')} already are`;
271+
const message = app === undefined
272+
? `Default record page conflict: page '${saveName}' cannot be ${verb} as the default record page of object `
273+
+ `'${object}': ${others}. `
274+
+ (unique.some((h) => h.shipped)
275+
? `Save '${saveName}' with \`isDefault: false\`: a page a package ships stays the default unless the `
276+
+ `deployment is composed without that package's page.`
277+
: `Save '${saveName}' with \`isDefault: false\`, or first save ${unique.map((h) => `'${h.page}'`).join(' and ')} `
278+
+ `with \`isDefault: false\` to make '${saveName}' the default instead.`)
279+
+ ` At most one \`isDefault: true\` record page may exist for an object `
280+
+ `in a deployment: the record view opens that page with no tie-break.`
281+
: `Default record page conflict: page '${saveName}' cannot be ${verb} as the record page of object `
282+
+ `'${object}' in app '${app}': ${others}. `
283+
+ (unique.some((h) => h.shipped)
284+
? `Save '${saveName}' without '${app}' in \`apps\`: a page a package ships stays that app's page unless the `
285+
+ `deployment is composed without that package's page.`
286+
: `Save '${saveName}' without '${app}' in \`apps\`, or first save ${unique.map((h) => `'${h.page}'`).join(' and ')} `
287+
+ `without it to make '${saveName}' that app's page instead.`)
288+
+ ` At most one record page may name an app in \`apps\` for an object: that app's record view `
289+
+ `opens it with no tie-break.`;
290+
const err = new Error(message) as Error & {
291+
code: 'DEFAULT_RECORD_PAGE_CONFLICT'; status: 422; object: string; app?: string; holders: readonly Holder[];
292+
};
293+
err.code = 'DEFAULT_RECORD_PAGE_CONFLICT';
294+
err.status = 422;
295+
err.object = object;
296+
if (app !== undefined) err.app = app;
297+
err.holders = unique;
298+
return err;
241299
}
242-
if (holders.length === 0) return undefined;
243-
244-
const seen = new Set<string>();
245-
const unique = holders.filter((h) => {
246-
const key = `${h.packageId ?? ''}|${h.page}`;
247-
if (seen.has(key)) return false;
248-
seen.add(key);
249-
return true;
250-
});
251-
const describe = (h: Holder) =>
252-
h.packageId ? `page '${h.page}' of package '${h.packageId}'` : `page '${h.page}', authored in this system`;
253-
const named = unique.map(describe);
254-
const others = named.length === 1 ? `${named[0]} already is` : `${named.join(' and ')} already are`;
255-
const remedy = unique.some((h) => h.shipped)
256-
? `Save '${saveName}' with \`isDefault: false\`: a page a package ships stays the default unless the `
257-
+ `deployment is composed without that package's page.`
258-
: `Save '${saveName}' with \`isDefault: false\`, or first save ${unique.map((h) => `'${h.page}'`).join(' and ')} `
259-
+ `with \`isDefault: false\` to make '${saveName}' the default instead.`;
260-
const err = new Error(
261-
`Default record page conflict: page '${saveName}' cannot be ${verb} as the default record page of object `
262-
+ `'${object}': ${others}. ${remedy} At most one \`isDefault: true\` record page may exist for an object `
263-
+ `in a deployment: the record view opens that page with no tie-break.`,
264-
) as Error & { code: 'DEFAULT_RECORD_PAGE_CONFLICT'; status: 422; object: string; holders: readonly Holder[] };
265-
err.code = 'DEFAULT_RECORD_PAGE_CONFLICT';
266-
err.status = 422;
267-
err.object = object;
268-
err.holders = unique;
269-
return err;
300+
return undefined;
270301
}
271302

272303
/**

‎packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,9 +370,10 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no
370370
// override declaration, on each overridable type. The excluded types
371371
// (`agent`, `field`, `hook`, `object`, `position`, `skill`, `tool`) do not
372372
// gain it, and their counts stay.
373+
// [#28979] `page` 27 → 28: `apps`, the app scope of a record page.
373374
const CARD_PROPERTY_COUNTS: Record<string, number> = {
374375
agent: 24, app: 24, dashboard: 21, dataset: 17, field: 82, flow: 23,
375-
hook: 23, object: 49, page: 27, position: 13, report: 24, skill: 16, tool: 14,
376+
hook: 23, object: 49, page: 28, position: 13, report: 24, skill: 16, tool: 14,
376377
};
377378

378379
it.each(Object.entries(CARD_PROPERTY_COUNTS))(

‎packages/objectql/src/default-record-page-save-door.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,79 @@ describe.each(TOPOLOGIES)('%s kernel — the page parse at the save door, and ro
342342
expect((again as { error: any }).error?.status).toBe(422);
343343
});
344344
});
345+
346+
// ── The app-scoped record page (`PageSchema.apps`, ruling A on #28979) ──
347+
//
348+
// The default's rule one level down: at most one record page per object PER
349+
// APP, refused by the same door with the same code, naming both pages and the
350+
// app. The default and an app's page are two slots and coexist.
351+
352+
const DULY = 'com.acme.duly';
353+
354+
/** An app-scoped record page of `object`; not a default unless `extra` says so. */
355+
const scopedPage = (name: string, object: string, apps: string[], extra: Record<string, unknown> = {}) =>
356+
recordPage(name, object, false, { apps, ...extra });
357+
358+
function expectAppRefusal(outcome: { ok: boolean; error?: any }, saved: string, object: string, app: string, holder: string) {
359+
expect(outcome.ok, 'the save was refused').toBe(false);
360+
const err = outcome.error;
361+
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'DEFAULT_RECORD_PAGE_CONFLICT', status: 422 });
362+
expect({ object: err.object, app: err.app }).toEqual({ object, app });
363+
expect(String(err.message).split('. ')[0]).toBe(
364+
`Default record page conflict: page '${saved}' cannot be saved as the record page of object `
365+
+ `'${object}' in app '${app}': ${holder} already is`,
366+
);
367+
}
368+
369+
describe.each(TOPOLOGIES)('%s kernel — a page saved as a second record page of an object in one app', (topology) => {
370+
it('is refused over a page a package ships for that app, naming both and the app, and stores nothing', async () => {
371+
const { engine, save, storedPages } = await boot(topology);
372+
engine.registerApp({ id: DULY, name: 'duly', pages: [scopedPage('duly_member', 'sys_user', ['duly'])] });
373+
const outcome = await save('env_member', scopedPage('env_member', 'sys_user', ['duly']));
374+
expectAppRefusal(outcome, 'env_member', 'sys_user', 'duly', `page 'duly_member' of package '${DULY}'`);
375+
expect(String((outcome as { error?: any }).error?.message)).toContain(
376+
"Save 'env_member' without 'duly' in `apps`: a page a package ships stays that app's page unless the "
377+
+ "deployment is composed without that package's page.",
378+
);
379+
expect(storedPages()).toEqual([]);
380+
});
381+
382+
it('is refused over a page the environment already stored for that app, naming it', async () => {
383+
const { save, storedPages } = await boot(topology);
384+
expect((await save('env_member_a', scopedPage('env_member_a', 'acme_task', ['duly']))).ok).toBe(true);
385+
const outcome = await save('env_member_b', scopedPage('env_member_b', 'acme_task', ['crm', 'duly']));
386+
expectAppRefusal(outcome, 'env_member_b', 'acme_task', 'duly', "page 'env_member_a', authored in this system");
387+
expect(storedPages()).toEqual(['env_member_a']);
388+
});
389+
390+
it('control: the same page scoped to another app is accepted', async () => {
391+
const { engine, save, storedPages } = await boot(topology);
392+
engine.registerApp({ id: DULY, name: 'duly', pages: [scopedPage('duly_member', 'sys_user', ['duly'])] });
393+
const outcome = await save('env_member', scopedPage('env_member', 'sys_user', ['crm']));
394+
expect(outcome.ok, String((outcome as any).error?.message)).toBe(true);
395+
expect(storedPages()).toEqual(['env_member']);
396+
});
397+
398+
it('control: an app\'s page is saved beside the package\'s default for the same object — two slots', async () => {
399+
const { save } = await boot(topology);
400+
const outcome = await save('env_org_member', scopedPage('env_org_member', 'acme_org', ['duly']));
401+
expect(outcome.ok, String((outcome as any).error?.message)).toBe(true);
402+
});
403+
404+
it('control: a save under the app page\'s own name (an overlay by name) stays accepted', async () => {
405+
const { save } = await boot(topology);
406+
expect((await save('env_member_a', scopedPage('env_member_a', 'acme_task', ['duly']))).ok).toBe(true);
407+
const again = await save('env_member_a', scopedPage('env_member_a', 'acme_task', ['duly'], { label: 'Renamed' }));
408+
expect(again.ok, String((again as any).error?.message)).toBe(true);
409+
});
410+
411+
it('`apps` on a home page is refused by the page parse, and nothing is stored', async () => {
412+
const { save, storedPages } = await boot(topology);
413+
const outcome = await save('env_home', { name: 'env_home', label: 'Home', type: 'home', apps: ['duly'], regions: [] });
414+
expect(outcome.ok).toBe(false);
415+
const err = (outcome as { error: any }).error;
416+
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
417+
expect(String(err.message)).toContain("`apps` is refused on a `type: 'home'` page");
418+
expect(storedPages()).toEqual([]);
419+
});
420+
});

0 commit comments

Comments
 (0)