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
29 changes: 29 additions & 0 deletions .changeset/28979-page-app-scoped-record-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@objectstack/spec': minor
'@objectstack/objectql': minor
'@objectstack/metadata-protocol': minor
---

feat(spec): a record page can name the apps whose routes open it for its object (`PageSchema.apps`)

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

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:

```ts
definePage({
name: 'duly_member',
label: 'Member',
type: 'record',
object: 'sys_user',
apps: ['duly'],
regions: [/* … */],
});
```

- **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.
- **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.
- **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.
- **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.
- **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.
1 change: 1 addition & 0 deletions content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ View filter rule
| **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. |
| **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. |
| **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. |
| **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. |
| **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. |
| **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: (object \| object)[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |
Expand Down
127 changes: 79 additions & 48 deletions packages/metadata-protocol/src/protocol-save-door-refusals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,21 @@ export async function defaultRecordPageSaveRefusal(
verb: 'saved' | 'published' | 'restored' = 'saved',
): Promise<(Error & { code: 'DEFAULT_RECORD_PAGE_CONFLICT'; status: 422 }) | undefined> {
if (type !== 'page') return undefined;
// Every slot the page holds: its object's default (no app), and the record
// page of its object inside each app its `apps` names (ruling A on
// objectstack#28979) — each judged by the one reading, `defaultRecordPageObject`.
const slots: Array<{ object: string; app?: string }> = [];
const object = defaultRecordPageObject(item);
if (object === undefined) return undefined;
if (object !== undefined) slots.push({ object });
const apps = (item as { apps?: unknown } | null | undefined)?.apps;
if (Array.isArray(apps)) {
for (const app of new Set(apps.filter((a): a is string => typeof a === 'string'))) {
const scoped = defaultRecordPageObject(item, app);
if (scoped !== undefined) slots.push({ object: scoped, app });
}
}
if (slots.length === 0) return undefined;

// `shipped`: a page a package ships through the registry, which no save
// can edit; a stored row (bound to a package or not) is editable here.
type Holder = { page: string; packageId?: string; shipped: boolean };
const holders: Holder[] = [];
let records: any[] = [];
try {
// [ADR-0131 D6] A save lands environment-wide, so the pages it meets
Expand All @@ -221,52 +229,75 @@ export async function defaultRecordPageSaveRefusal(
}
const stored = this.storedOverlayEntries({ type }, records);
const storedNames = new Set(stored.map((entry) => entry.name));
for (const entry of stored) {
if (entry.name === saveName || defaultRecordPageObject(entry.data) !== object) continue;
holders.push(entry.packageId
? { page: entry.name, packageId: entry.packageId, shipped: false }
: { page: entry.name, shipped: false });
}
const registry = this.engine.registry;
if (typeof registry?.defaultRecordPageHoldersOf === 'function') {
const registered = registry.defaultRecordPageHoldersOf(object) as Array<
{ kind: 'package'; packageId: string; page: string } | { kind: 'environment'; page: string }
>;
for (const holder of registered) {
if (holder.page === saveName || storedNames.has(holder.page)) continue;
holders.push(holder.kind === 'package'
? { page: holder.page, packageId: holder.packageId, shipped: true }
: { page: holder.page, shipped: false });

// `shipped`: a page a package ships through the registry, which no save
// can edit; a stored row (bound to a package or not) is editable here.
type Holder = { page: string; packageId?: string; shipped: boolean };
for (const { object, app } of slots) {
const holders: Holder[] = [];
for (const entry of stored) {
if (entry.name === saveName || defaultRecordPageObject(entry.data, app) !== object) continue;
holders.push(entry.packageId
? { page: entry.name, packageId: entry.packageId, shipped: false }
: { page: entry.name, shipped: false });
}
if (typeof registry?.defaultRecordPageHoldersOf === 'function') {
const registered = (app === undefined
? registry.defaultRecordPageHoldersOf(object)
: registry.defaultRecordPageHoldersOf(object, app)) as Array<
{ kind: 'package'; packageId: string; page: string } | { kind: 'environment'; page: string }
>;
for (const holder of registered) {
if (holder.page === saveName || storedNames.has(holder.page)) continue;
holders.push(holder.kind === 'package'
? { page: holder.page, packageId: holder.packageId, shipped: true }
: { page: holder.page, shipped: false });
}
}
if (holders.length === 0) continue;

const seen = new Set<string>();
const unique = holders.filter((h) => {
const key = `${h.packageId ?? ''}|${h.page}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const describe = (h: Holder) =>
h.packageId ? `page '${h.page}' of package '${h.packageId}'` : `page '${h.page}', authored in this system`;
const named = unique.map(describe);
const others = named.length === 1 ? `${named[0]} already is` : `${named.join(' and ')} already are`;
const message = app === undefined
? `Default record page conflict: page '${saveName}' cannot be ${verb} as the default record page of object `
+ `'${object}': ${others}. `
+ (unique.some((h) => h.shipped)
? `Save '${saveName}' with \`isDefault: false\`: a page a package ships stays the default unless the `
+ `deployment is composed without that package's page.`
: `Save '${saveName}' with \`isDefault: false\`, or first save ${unique.map((h) => `'${h.page}'`).join(' and ')} `
+ `with \`isDefault: false\` to make '${saveName}' the default instead.`)
+ ` At most one \`isDefault: true\` record page may exist for an object `
+ `in a deployment: the record view opens that page with no tie-break.`
: `Default record page conflict: page '${saveName}' cannot be ${verb} as the record page of object `
+ `'${object}' in app '${app}': ${others}. `
+ (unique.some((h) => h.shipped)
? `Save '${saveName}' without '${app}' in \`apps\`: a page a package ships stays that app's page unless the `
+ `deployment is composed without that package's page.`
: `Save '${saveName}' without '${app}' in \`apps\`, or first save ${unique.map((h) => `'${h.page}'`).join(' and ')} `
+ `without it to make '${saveName}' that app's page instead.`)
+ ` At most one record page may name an app in \`apps\` for an object: that app's record view `
+ `opens it with no tie-break.`;
const err = new Error(message) as Error & {
code: 'DEFAULT_RECORD_PAGE_CONFLICT'; status: 422; object: string; app?: string; holders: readonly Holder[];
};
err.code = 'DEFAULT_RECORD_PAGE_CONFLICT';
err.status = 422;
err.object = object;
if (app !== undefined) err.app = app;
err.holders = unique;
return err;
}
if (holders.length === 0) return undefined;

const seen = new Set<string>();
const unique = holders.filter((h) => {
const key = `${h.packageId ?? ''}|${h.page}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const describe = (h: Holder) =>
h.packageId ? `page '${h.page}' of package '${h.packageId}'` : `page '${h.page}', authored in this system`;
const named = unique.map(describe);
const others = named.length === 1 ? `${named[0]} already is` : `${named.join(' and ')} already are`;
const remedy = unique.some((h) => h.shipped)
? `Save '${saveName}' with \`isDefault: false\`: a page a package ships stays the default unless the `
+ `deployment is composed without that package's page.`
: `Save '${saveName}' with \`isDefault: false\`, or first save ${unique.map((h) => `'${h.page}'`).join(' and ')} `
+ `with \`isDefault: false\` to make '${saveName}' the default instead.`;
const err = new Error(
`Default record page conflict: page '${saveName}' cannot be ${verb} as the default record page of object `
+ `'${object}': ${others}. ${remedy} At most one \`isDefault: true\` record page may exist for an object `
+ `in a deployment: the record view opens that page with no tie-break.`,
) as Error & { code: 'DEFAULT_RECORD_PAGE_CONFLICT'; status: 422; object: string; holders: readonly Holder[] };
err.code = 'DEFAULT_RECORD_PAGE_CONFLICT';
err.status = 422;
err.object = object;
err.holders = unique;
return err;
return undefined;
}

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

it.each(Object.entries(CARD_PROPERTY_COUNTS))(
Expand Down
76 changes: 76 additions & 0 deletions packages/objectql/src/default-record-page-save-door.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,79 @@ describe.each(TOPOLOGIES)('%s kernel — the page parse at the save door, and ro
expect((again as { error: any }).error?.status).toBe(422);
});
});

// ── The app-scoped record page (`PageSchema.apps`, ruling A on #28979) ──
//
// The default's rule one level down: at most one record page per object PER
// APP, refused by the same door with the same code, naming both pages and the
// app. The default and an app's page are two slots and coexist.

const DULY = 'com.acme.duly';

/** An app-scoped record page of `object`; not a default unless `extra` says so. */
const scopedPage = (name: string, object: string, apps: string[], extra: Record<string, unknown> = {}) =>
recordPage(name, object, false, { apps, ...extra });

function expectAppRefusal(outcome: { ok: boolean; error?: any }, saved: string, object: string, app: string, holder: string) {
expect(outcome.ok, 'the save was refused').toBe(false);
const err = outcome.error;
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'DEFAULT_RECORD_PAGE_CONFLICT', status: 422 });
expect({ object: err.object, app: err.app }).toEqual({ object, app });
expect(String(err.message).split('. ')[0]).toBe(
`Default record page conflict: page '${saved}' cannot be saved as the record page of object `
+ `'${object}' in app '${app}': ${holder} already is`,
);
}

describe.each(TOPOLOGIES)('%s kernel — a page saved as a second record page of an object in one app', (topology) => {
it('is refused over a page a package ships for that app, naming both and the app, and stores nothing', async () => {
const { engine, save, storedPages } = await boot(topology);
engine.registerApp({ id: DULY, name: 'duly', pages: [scopedPage('duly_member', 'sys_user', ['duly'])] });
const outcome = await save('env_member', scopedPage('env_member', 'sys_user', ['duly']));
expectAppRefusal(outcome, 'env_member', 'sys_user', 'duly', `page 'duly_member' of package '${DULY}'`);
expect(String((outcome as { error?: any }).error?.message)).toContain(
"Save 'env_member' without 'duly' in `apps`: a page a package ships stays that app's page unless the "
+ "deployment is composed without that package's page.",
);
expect(storedPages()).toEqual([]);
});

it('is refused over a page the environment already stored for that app, naming it', async () => {
const { save, storedPages } = await boot(topology);
expect((await save('env_member_a', scopedPage('env_member_a', 'acme_task', ['duly']))).ok).toBe(true);
const outcome = await save('env_member_b', scopedPage('env_member_b', 'acme_task', ['crm', 'duly']));
expectAppRefusal(outcome, 'env_member_b', 'acme_task', 'duly', "page 'env_member_a', authored in this system");
expect(storedPages()).toEqual(['env_member_a']);
});

it('control: the same page scoped to another app is accepted', async () => {
const { engine, save, storedPages } = await boot(topology);
engine.registerApp({ id: DULY, name: 'duly', pages: [scopedPage('duly_member', 'sys_user', ['duly'])] });
const outcome = await save('env_member', scopedPage('env_member', 'sys_user', ['crm']));
expect(outcome.ok, String((outcome as any).error?.message)).toBe(true);
expect(storedPages()).toEqual(['env_member']);
});

it('control: an app\'s page is saved beside the package\'s default for the same object — two slots', async () => {
const { save } = await boot(topology);
const outcome = await save('env_org_member', scopedPage('env_org_member', 'acme_org', ['duly']));
expect(outcome.ok, String((outcome as any).error?.message)).toBe(true);
});

it('control: a save under the app page\'s own name (an overlay by name) stays accepted', async () => {
const { save } = await boot(topology);
expect((await save('env_member_a', scopedPage('env_member_a', 'acme_task', ['duly']))).ok).toBe(true);
const again = await save('env_member_a', scopedPage('env_member_a', 'acme_task', ['duly'], { label: 'Renamed' }));
expect(again.ok, String((again as any).error?.message)).toBe(true);
});

it('`apps` on a home page is refused by the page parse, and nothing is stored', async () => {
const { save, storedPages } = await boot(topology);
const outcome = await save('env_home', { name: 'env_home', label: 'Home', type: 'home', apps: ['duly'], regions: [] });
expect(outcome.ok).toBe(false);
const err = (outcome as { error: any }).error;
expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 });
expect(String(err.message)).toContain("`apps` is refused on a `type: 'home'` page");
expect(storedPages()).toEqual([]);
});
});
Loading
Loading