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
39 changes: 39 additions & 0 deletions .changeset/core-server-dispatcher-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@object-ui/core": minor
"@object-ui/app-shell": patch
---

`@object-ui/core` now ships the server-action dispatcher factory —
`createServerActionHandler({ fetch, baseUrl, resolveObject, ... })` — so any
consumer of the runner (standalone renderers, SDUI hosts, embedded usage) can
run `action.body` script actions by registering the produced handler, instead
of dead-ending on the built-in `executeScript`'s "must be executed server-side"
error with no supported way to make it run (objectui#2904, the follow-up
objectui#2896 deferred).

The factory is deliberately opinion-free about the three things core has no
business deciding — auth (`fetch` is an injected authenticated wrapper), origin
(`baseUrl` string or thunk; no bundler env convention), and fallback object
scope (`resolveObject`) — and owns everything protocol-shaped, once:

- name-based action identity (ADR-0110 D1 — `target` is a binding expression,
never an identity);
- the record-id resolution dance, also exported as
`resolveServerActionRecordId` (`_rowRecord`, `recordIdField`, toolbar
selection fallback with its single/zero-select guards, aggregate
`_selectedIds` bypass), replaceable wholesale via `resolveRecordId` for
hosts with their own policy (record pages);
- a re-entrancy guard per action+record;
- the `/actions` response-envelope rule: `interpretActionResponse`,
`readActionPayload` and `actionErrorDetail` moved from `@object-ui/app-shell`
internals into core and are now public exports.

`@object-ui/app-shell`'s two hand-rolled copies of this POST —
`useConsoleActionRuntime.serverActionHandler` and `RecordDetailView`'s — are
collapsed into one console wrapper (`createConsoleServerActionHandler`) that
layers the browser-only choreography (popup pre-open dance, zero-roundtrip
`newTabUrl` fast path, `redirectUrl` convention) over the core factory. The
copies had already drifted twice (objectstack#3913 — envelope; framework#3935 —
identity, fixed in one copy only): RecordDetailView now also dispatches by
declarative `name` instead of `target || name`, and no longer leaks the
client-side `_rowRecord` stash to the server.
33 changes: 32 additions & 1 deletion content/docs/guide/architecture-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,43 @@ Executes action schemas and returns directives:

| Type | Description |
|------|-------------|
| `script` | Execute inline JavaScript |
| `script` | Dispatch a named/registered script action — an `action.body` always executes **server-side** |
| `url` | Navigate to a URL |
| `api` | Make an API request (AJAX) |
| `modal` | Open a modal with a nested schema |
| `flow` | Execute a multi-step action sequence |

### Server Action Dispatch (`serverActionHandler.ts`)

`ActionSchema.body` (the spec's preferred binding for script actions) executes
server-side via `POST /api/v1/actions/{object}/{action}` — the client
dispatches, it never interprets a body (a browser cannot enforce the L2
sandbox's capabilities/timeout/memory contract, and L1 is formula-engine CEL,
a different dialect from the `${...}` evaluator).

`createServerActionHandler` is the core factory every consumer uses to build
that dispatch. It owns the protocol (name-based action identity, record-id
resolution, re-entrancy guard, the `/actions` response-envelope rule) and
injects the three things core has no opinion about:

```typescript
import { createServerActionHandler } from '@object-ui/core';

const script = createServerActionHandler({
fetch: myAuthenticatedFetch, // auth is yours
baseUrl: 'https://api.example.com', // origin is yours ('' = same-origin)
resolveObject: () => currentObject, // fallback object scope is yours
onRefresh: () => notifyDataChanged(), // data invalidation is yours
});

// Registered handlers beat the built-in executors:
<ActionProvider handlers={{ script }} ... />
```

The console builds on the same factory (`@object-ui/app-shell`'s
`createConsoleServerActionHandler` adds the popup pre-open dance and the
`redirectUrl` convention on top).

### TransactionManager (`TransactionManager.ts`)

Wraps multi-step actions in transactions for consistency, supporting rollback on failure.
Expand Down
64 changes: 35 additions & 29 deletions packages/app-shell/src/actions-envelope.ratchet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* objectstack#3913 ratchet — every `/api/v1/actions` caller goes through
* `interpretActionResponse`.
* objectstack#3913 ratchet — no app-shell file dispatches `/api/v1/actions`
* by hand.
*
* The bug this guards: the `/actions` response wraps TWICE (the route's own
* `{success, data}` inside the dispatcher's), and a failure has three shapes
Expand All @@ -15,14 +15,16 @@
* `marketplaceApi.installPackage` had the same hole and could report a package
* as installed when it was not.
*
* Reading the envelope by hand is the anti-pattern, not any particular way of
* reading it wrong: four hand-rolled copies produced three different bugs
* (missed inner failure, a `{message}` object handed to `toast.error()` as a
* React child → React #31, and `redirectUrl` read one level too shallow so it
* never fired). One helper, one rule.
* Since #2904 the dispatch itself lives in `@object-ui/core`
* (`createServerActionHandler`, which applies `interpretActionResponse` — the
* envelope rule, also moved there) and the console layers its DOM choreography
* on top through `utils/consoleServerAction`. The ratchet is therefore
* STRONGER than it used to be: an app-shell file has no business naming the
* action route in code at all.
*
* If this fails: don't hand-roll the check. Import `interpretActionResponse`
* from `utils/actionResponse` — or, better, call the action through
* If this fails: don't hand-roll the POST. Register a handler built with
* `createConsoleServerActionHandler` (console surfaces) or core's
* `createServerActionHandler` — or call the action through
* `@objectstack/client`, which folds every shape into `{ success, data?, error? }`.
*/

Expand All @@ -34,16 +36,12 @@ import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const appShellSrc = here;

/** The helper that owns the rule — exempt from its own guard. */
const OWNER = path.join(appShellSrc, 'utils', 'actionResponse.ts');

/** Files allowed to name the route without going through the helper. */
/** Files allowed to name the route without going through the core dispatcher. */
const EXEMPT = new Set<string>([
OWNER,
// The cloud marketplace install posts to a *cloud* origin's action route
// and consumes `InstallResponse`, not `ActionResult`. It still has to
// honour the envelope, and does — covered by its own tests — but it is not
// an ActionRunner handler, so it does not use this helper.
// an ActionRunner handler, so it does not use the core dispatcher.
path.join(appShellSrc, 'console', 'marketplace', 'marketplaceApi.ts'),
]);

Expand All @@ -70,26 +68,34 @@ function stripComments(src: string): string {
.replace(/(^|[^:])\/\/.*$/gm, '$1');
}

describe('objectstack#3913 ratchet — /actions callers use interpretActionResponse', () => {
const callers = walk(appShellSrc)
.filter((f) => !EXEMPT.has(f) && ROUTE.test(stripComments(readFileSync(f, 'utf8'))));
describe('objectstack#3913 ratchet — /actions dispatch goes through the core dispatcher', () => {
const files = walk(appShellSrc);

const offenders = callers
.filter((f) => !readFileSync(f, 'utf8').includes('interpretActionResponse'))
const offenders = files
.filter((f) => !EXEMPT.has(f) && ROUTE.test(stripComments(readFileSync(f, 'utf8'))))
.map((f) => path.relative(appShellSrc, f));

it('no app-shell file interprets an /actions response by hand', () => {
it('no app-shell file names the /actions route in code (dispatch is core-owned)', () => {
expect(offenders).toEqual([]);
});

it('still guards something — the known callers are present', () => {
it('still guards something — the known dispatch surfaces route through the shared wrapper', () => {
// A ratchet that matches nothing passes vacuously forever. Pin that the
// two handlers it exists for are actually being scanned.
expect(callers.map((f) => path.relative(appShellSrc, f))).toEqual(
expect.arrayContaining([
path.join('hooks', 'useConsoleActionRuntime.tsx'),
path.join('views', 'RecordDetailView.tsx'),
]),
);
// two surfaces the hand-rolled copies lived in still exist and now build
// their handler with `createConsoleServerActionHandler` — the moment one
// reverts to a hand-rolled fetch, the route string reappears and the
// offenders assertion above catches it.
const surfaces = [
path.join('hooks', 'useConsoleActionRuntime.tsx'),
path.join('views', 'RecordDetailView.tsx'),
];
for (const rel of surfaces) {
const src = readFileSync(path.join(appShellSrc, rel), 'utf8');
expect(src, `${rel} should dispatch via createConsoleServerActionHandler`)
.toContain('createConsoleServerActionHandler');
}
// …and the wrapper itself delegates to core rather than fetching.
const wrapper = readFileSync(path.join(appShellSrc, 'utils', 'consoleServerAction.ts'), 'utf8');
expect(wrapper).toContain('createServerActionHandler');
});
});
Loading
Loading