Skip to content

Commit 721669c

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-ybjm3h
2 parents 4003f50 + a4eae06 commit 721669c

18 files changed

Lines changed: 1361 additions & 317 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: an object's own actions are stored as their `ActionSchema` parse, so `requiresFeature` takes effect on a raw manifest's object actions
6+
7+
Clause-②: no (narrowing: an object's own action that fails `ActionSchema` is refused at the runtime registration door, as #25649 did for standalone actions)
8+
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: `ActionSchema` and `ObjectSchema` are byte-unchanged, and no spec key, authorable spelling, stored row or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no old-to-new mapping to record. What changes is one runtime door converging on the existing contract: the schema registry now stores a code package's object with its own actions as their parse and refuses an object whose action does not parse. Every body it refuses was already off-contract, and refused by the authoring doors (`defineStack`, `os validate`, the metadata save door), before this change. The remedy is a correction of that body against the issues the refusal lists, not a conversion between two contract versions. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
The schema registry now parses an object's own `actions` through `ActionSchema` when a code package registers the object (`SchemaRegistry.registerObject`), and stores the parsed actions. A standalone `action` item has been stored this way already; both now go through one parse. Before this, an object that reached the registry without a parse kept its actions as authored. Examples are a raw plugin manifest, a `strict: false` stack, and a programmatic `registerApp`. Such an object was served with its actions unparsed: a declared `requiresFeature` (or `requiresMembershipReach`) was never lowered into `visible`, so the gate did nothing.
14+
15+
What the parse does to such an action, as with any parsed action:
16+
17+
- it lowers `requiresFeature` / `requiresMembershipReach` into `visible`;
18+
- it fills schema defaults;
19+
- it wraps a CEL string shorthand in `{ dialect, source }`.
20+
21+
An object a strict `defineStack` or `ObjectSchema.create` already parsed is stored unchanged. A registration with no package, and a tenant-authored body (the metadata save door's write-through, the boot rehydration of stored rows), is not judged here.
22+
23+
**BREAKING** for a package that registers an object whose own action `ActionSchema` does not accept.
24+
25+
- **Before.** The registry stored such an object as written, and served the action with any declared gate inert.
26+
- **Now.** The object's registration is refused with `INVALID_METADATA` (422), and nothing of it is stored. The error names the action, its object and its package, and `issues[]` gives the path (`actions.N.KEY`) and message of each failure.
27+
- **Fix.** Correct the action as `issues[]` describes. An object that passes `os validate`, or a strict `defineStack`, is unaffected.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
docs(spec): three comments describe the shared-chunk bundle layout, not the per-entry one
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The module doc block in `@objectstack/spec`'s root entry, the `@__PURE__` note on the conversions table, and the Package API assembled-entry doc block (the generated reference page of the same name) still said each entry is a self-contained, single-file bundle. The build is one module graph: a module two entries reach ships once, in a shared chunk, and an entry links every chunk its graph touches. The sentences now say that. The doc text rides in the shipped declaration files, which is why this is a patch entry; no export, key, value or behaviour changes.
11+
12+
What to do: nothing.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-grid': patch
3+
---
4+
5+
fix(plugin-grid): a grouped grid orders a level's groups by its `grouping.fields[].orderBy`, the label still from `field`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A view grouped by `period_label` with `orderBy: 'period_start', order: 'desc'` now lists one header per label in descending `period_start` order, where it used to list the labels as text (`Nov 2026` before `Oct 2026` before `Q1 2027`).
11+
12+
- **Server-grouped grid.** The grid forwards the level's `orderBy` into the group header query, so the query carries the `min` (asc) / `max` (desc) column the spec compiles for it, and sorts the header rows by the column `listViewGroupOrderColumn` names. An `orderBy` field the user may not read is not forwarded: that level orders by its own key.
13+
- **Rows handed in whole.** Each group's order value is the `min` (asc) or `max` (desc) of `orderBy` over its rows, so a group sits where its first row would if the rows were sorted by `orderBy`.
14+
- **Both paths.** A group with no `orderBy` value sorts last in both directions; equal values fall back to the existing key comparison (declared option order, then label) in the same direction. A level without `orderBy`, or with `orderBy` naming the grouping field itself, orders exactly as before.

‎content/docs/references/api/package-api-assembled.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ driver-config validators and the server-only pg URL grammar. Declared inside
2828
`@objectstack/spec/api` (#17517), that tree became part of every bundle of the
2929
entry — and a browser module that imported two string constants from
3030
`./sortability.zod` paid for all of it, roughly doubling its gzipped bundle,
31-
because the entry ships as one self-contained bundle and little of that tree
32-
can be dropped by a consumer's tree-shaking. The maintainer ruling on #18576
31+
because the entry links every chunk its module graph touches and little of
32+
that tree can be dropped by a consumer's tree-shaking. The maintainer ruling on #18576
3333
(letter B) removed the cost rather than watching it: the browser-facing
3434
`./api` no longer carries these declarations, and this entry does.
3535

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25744] An object's OWN `actions`, registered by a code package through
5+
* {@link SchemaRegistry.registerObject}, are stored as their `ActionSchema`
6+
* parse — the parse a standalone `action` item is stored as since #25649 — so a
7+
* declared `requiresFeature` / `requiresMembershipReach` is lowered into
8+
* `visible`, and an action that does not parse is refused (`INVALID_METADATA`,
9+
* 422) before anything of the object is stored.
10+
*
11+
* Before, a raw manifest or a `strict: false` stack registered with
12+
* `ObjectQL.registerApp` stored its objects' actions as written: served by
13+
* `GET /api/v1/meta/objects/:name` (`protocol.getMetaItem({ type: 'object' })`)
14+
* with `requiresFeature` riding along raw and no `visible` — a feature gate no
15+
* reader enforces. A strict `defineStack`, `os validate` and the metadata save
16+
* door already parse the same body through `ActionSchema`.
17+
*/
18+
19+
import { describe, it, expect } from 'vitest';
20+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
21+
import { defineStack } from '@objectstack/spec';
22+
import { ObjectSchema } from '@objectstack/spec/data';
23+
import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core';
24+
import { ObjectQL } from './engine.js';
25+
26+
/** One gated declaration, authored on an object's own action. */
27+
const GATED = {
28+
name: 'gated', label: 'Gated', type: 'url' as const, target: 'https://example.com/gated',
29+
locations: ['record_header' as const], visible: "record.name != ''",
30+
requiresFeature: 'multiOrgEnabled' as const,
31+
};
32+
const LOWERED = { dialect: 'cel', source: "(record.name != '') && features.multiOrgEnabled != false" };
33+
34+
const objectWith = (actions: unknown[]) => ({
35+
name: 'oa_org', label: 'Org',
36+
fields: { name: { name: 'name', type: 'text', label: 'Name' } },
37+
actions,
38+
});
39+
const rawManifest = (actions: unknown[]) => ({ id: 'com.test.raw', name: 'raw', objects: [objectWith(actions)] });
40+
41+
/** A driver that holds nothing: the reads below never reach a stored row. */
42+
const emptyDriver: any = {
43+
name: 'memory', version: '0.0.0', supports: {},
44+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
45+
async execute() { return null; },
46+
async find() { return []; }, async findOne() { return null; }, async count() { return 0; },
47+
};
48+
49+
/** The stored-metadata family the by-name read consults for an overlay row (none here). */
50+
const sysMetadataObject = {
51+
name: 'sys_metadata', label: 'System Metadata',
52+
fields: {
53+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
54+
type: { name: 'type', label: 'Type', type: 'text' as const, required: true },
55+
name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
56+
organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const },
57+
package_id: { name: 'package_id', label: 'Package', type: 'text' as const },
58+
metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const },
59+
state: { name: 'state', label: 'State', type: 'text' as const },
60+
},
61+
};
62+
63+
async function host() {
64+
const engine = new ObjectQL();
65+
engine.registerDriver(emptyDriver, true);
66+
await engine.init();
67+
engine.registry.registerObject(sysMetadataObject);
68+
for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any);
69+
return { engine, protocol: new ObjectStackProtocolImplementation(engine) };
70+
}
71+
72+
const servedAction = async (protocol: ObjectStackProtocolImplementation, name: string) => {
73+
const served: any = await protocol.getMetaItem({ type: 'object', name: 'oa_org' } as any);
74+
return (served.item.actions as any[]).find((a) => a?.name === name);
75+
};
76+
77+
const refusalOf = (fn: () => unknown): any => {
78+
try { fn(); } catch (e) { return e; }
79+
return undefined;
80+
};
81+
82+
describe("an object's own action is stored as its ActionSchema parse", () => {
83+
it('a raw manifest: GET /meta/object/<name> serves `requiresFeature` lowered into `visible`', async () => {
84+
const { engine, protocol } = await host();
85+
engine.registerApp(rawManifest([GATED]));
86+
const served = await servedAction(protocol, 'gated');
87+
expect(served.visible).toEqual(LOWERED);
88+
expect(served).not.toHaveProperty('requiresFeature');
89+
// The same lowering `ObjectSchema.create` gives a platform object's action of the same declaration.
90+
const authored: any = ObjectSchema.create(objectWith([GATED]) as any);
91+
expect(served.visible).toEqual(authored.actions[0].visible);
92+
// The registry's own resolved object holds the same parsed body.
93+
expect(engine.registry.getObject('oa_org')?.actions?.[0]).toEqual(served);
94+
});
95+
96+
it('an action with no `visible` of its own is served the gate alone', async () => {
97+
const { visible: _visible, ...ungated } = GATED;
98+
const { engine, protocol } = await host();
99+
engine.registerApp(rawManifest([ungated]));
100+
const served = await servedAction(protocol, 'gated');
101+
expect(served.visible).toEqual({ dialect: 'cel', source: 'features.multiOrgEnabled != false' });
102+
expect(served).not.toHaveProperty('requiresFeature');
103+
});
104+
105+
it('`requiresMembershipReach` is lowered the same way', async () => {
106+
const { requiresFeature: _feature, ...rest } = GATED;
107+
const { engine, protocol } = await host();
108+
engine.registerApp(rawManifest([{ ...rest, requiresMembershipReach: 'invite_member' }]));
109+
const served = await servedAction(protocol, 'gated');
110+
expect(served).not.toHaveProperty('requiresMembershipReach');
111+
expect(served.visible).toEqual(ObjectSchema.create(objectWith([{ ...rest, requiresMembershipReach: 'invite_member' }]) as any).actions![0].visible);
112+
});
113+
114+
it('a `strict: false` stack registered with registerApp is parsed at the registry', async () => {
115+
const stack: any = defineStack({
116+
manifest: { id: 'com.test.loose', name: 'loose', version: '1.0.0', type: 'app' },
117+
objects: [objectWith([GATED])],
118+
} as any, { strict: false });
119+
// The loose stack hands the body on as written.
120+
expect(stack.objects[0].actions[0]).toHaveProperty('requiresFeature');
121+
const { engine, protocol } = await host();
122+
engine.registerApp({ ...stack, ...stack.manifest });
123+
const served = await servedAction(protocol, 'gated');
124+
expect(served.visible).toEqual(LOWERED);
125+
expect(served).not.toHaveProperty('requiresFeature');
126+
});
127+
128+
it('a direct registerObject under a package is parsed; the caller\'s body is not mutated', async () => {
129+
const { engine } = await host();
130+
const def = objectWith([structuredClone(GATED)]);
131+
engine.registry.registerObject(def as any, 'com.test.direct');
132+
expect(engine.registry.getObject('oa_org')?.actions?.[0]).toMatchObject({ visible: LOWERED });
133+
expect(def.actions[0]).toHaveProperty('requiresFeature', 'multiOrgEnabled');
134+
});
135+
136+
it('control: an object a strict parse already produced is stored deep-equal to what it arrived as', async () => {
137+
const authored: any = ObjectSchema.create(objectWith([GATED]) as any);
138+
const { engine, protocol } = await host();
139+
engine.registerApp({ id: 'com.test.strict', name: 'strict', objects: [structuredClone(authored)] });
140+
expect(await servedAction(protocol, 'gated')).toEqual(authored.actions[0]);
141+
});
142+
143+
it('an action that does not parse is refused loudly, naming the action, its object and package — nothing stored', async () => {
144+
const { engine } = await host();
145+
const err = refusalOf(() => engine.registerApp(rawManifest([{ ...GATED, visible: false }])));
146+
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
147+
expect(err.message).toContain("Action 'gated' of object 'oa_org' in package 'com.test.raw' does not satisfy the action contract");
148+
expect(err.issues).toEqual(expect.arrayContaining([expect.objectContaining({ path: 'actions.0.requiresFeature' })]));
149+
expect(engine.registry.getObject('oa_org')).toBeUndefined();
150+
});
151+
152+
it('a refused re-registration leaves the object already registered under that package in place', async () => {
153+
const { engine } = await host();
154+
engine.registerApp(rawManifest([GATED]));
155+
const err = refusalOf(() => engine.registry.registerObject(objectWith([{ ...GATED, bogus: true }]) as any, 'com.test.raw'));
156+
expect(err).toMatchObject({ code: 'INVALID_METADATA', status: 422 });
157+
expect(err.issues).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'unrecognized_keys' })]));
158+
expect(engine.registry.getObject('oa_org')?.actions?.[0]).toMatchObject({ name: 'gated', visible: LOWERED });
159+
});
160+
});

0 commit comments

Comments
 (0)