From 9fd1aace1cba67da515e6920ac8efce7f980f548 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:44:59 +0000 Subject: [PATCH 1/2] feat(showcase): add the object-less (global) action specimen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3913 and its follow-up (#4005) fixed object-less action dispatch and the empty-object-segment route with no live specimen to exercise either. The showcase is the "one specimen of everything" app, yet every action in it declared an `objectName` — so the `global` engine key, and both object-less URL shapes, had nothing in-app that actually ran through them. #4005 could only verify routing end to end; a real dispatch was never observed. `showcase_portfolio_snapshot` closes that. It declares no `objectName`, so `ObjectQLPlugin.actionObjectKey` / AppPlugin's `action.object || 'global'` key it at `global`, and it is reachable at both shapes. The body is genuinely object-less — it counts across three objects, so there is no single record or object it could sensibly hang off — and `locations` is `global_nav` for the same reason. Verified live against the running showcase; both URL shapes return byte-identical single-wrapped results: POST /api/v1/actions/global/showcase_portfolio_snapshot POST /api/v1/actions//showcase_portfolio_snapshot -> 200 {"success":true,"data":{"ok":true,"scope":"global", "accounts":15,"projects":5,"invoices":13}} That is the first live confirmation of all three fixes in this arc acting together: the canonical `global` key (#3913), the empty-segment route (#4005), and single-wrap success (#3962). Two tests pin the specimen in `test/actions.test.ts`, next to the #2169 executability cases. The first is the one that matters: adding an `objectName` to this action would still build, still pass coverage.test.ts, and still work — while silently removing the app's only coverage of object-less dispatch. It asserts the action declares neither `objectName` nor `object`, and that it remains the only such action. Confirmed the guard fails when an `objectName` is added. Showcase build green (Actions 10 -> 11), 60 tests pass, typecheck and eslint clean. Example package is private, so the changeset releases nothing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F --- .changeset/showcase-global-action-specimen.md | 8 +++ examples/app-showcase/src/coverage.ts | 2 +- examples/app-showcase/src/ui/actions/index.ts | 50 +++++++++++++ examples/app-showcase/test/actions.test.ts | 72 ++++++++++++++++++- 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 .changeset/showcase-global-action-specimen.md diff --git a/.changeset/showcase-global-action-specimen.md b/.changeset/showcase-global-action-specimen.md new file mode 100644 index 0000000000..3e7d131328 --- /dev/null +++ b/.changeset/showcase-global-action-specimen.md @@ -0,0 +1,8 @@ +--- +--- + +Showcase example only, releases nothing: add `showcase_portfolio_snapshot`, the +first object-less (`global`) action in the app. #3913 and its follow-up fixed +object-less dispatch and the empty-object-segment route with no live specimen to +exercise them — the "one specimen of everything" app had no action without an +`objectName`. This is that specimen. diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 0f0e619c05..d0fb7a656f 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -95,7 +95,7 @@ export const KIND_COVERAGE: Record = { status: 'demonstrated', files: ['src/ui/actions/index.ts'], notes: - 'Every ActionType (script/url/flow/modal/api/form). `ActionParamGalleryAction` additionally exercises the ADR-0059 param-dialog widgets: one inline param per non-trivial type (richtext/color/date/select/number/autonumber) plus image/file uploads with multiple/accept/maxSize and the upload guard.', + "Every ActionType (script/url/flow/modal/api/form). `ActionParamGalleryAction` additionally exercises the ADR-0059 param-dialog widgets: one inline param per non-trivial type (richtext/color/date/select/number/autonumber) plus image/file uploads with multiple/accept/maxSize and the upload guard. `PortfolioSnapshotAction` is the OBJECT-LESS specimen (framework#3913): it declares no `objectName`, so it keys at `global` — the app's only live exerciser of object-less dispatch and of both object-less URL shapes (`/actions/global/:action` and `/actions//:action`).", }, report: { status: 'demonstrated', files: ['src/ui/reports/index.ts'] }, dataset: { status: 'demonstrated', files: ['src/ui/datasets/index.ts'] }, diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index fe44e37a6a..8348d72a20 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -275,6 +275,55 @@ export const ArchiveTaskAction = defineAction({ refreshAfter: false, }); +/** + * script, **OBJECT-LESS** — the `global` action specimen (framework#3913). + * + * Every other action here declares an `objectName`. This one deliberately does + * NOT, which is the whole point: `objectName` is optional, and an action + * without one is an *object-less* action. `AppPlugin` registers it under the + * canonical `'global'` engine key (`action.object || 'global'`), and it is + * reachable over REST at BOTH object-less URL shapes: + * + * POST /api/v1/actions/global/showcase_portfolio_snapshot + * POST /api/v1/actions//showcase_portfolio_snapshot ← empty object segment + * + * Why the app needed this: framework#3913 was filed because object-less actions + * were unreachable (registered under `'global'`, looked up under `'*'`), and its + * follow-up found the empty-segment URL had no route registration at all. Both + * were fixed blind — the showcase, the "one specimen of everything" app, had no + * object-less action, so neither the dispatch path nor either URL shape had a + * live specimen to exercise. This is that specimen. + * + * The body is genuinely object-less: it counts across SEVERAL objects, so there + * is no single record or object the action could sensibly hang off. `location` + * is `global_nav` for the same reason — an object-less action has no row and no + * record header to render on. + */ +export const PortfolioSnapshotAction = defineAction({ + name: 'showcase_portfolio_snapshot', + label: 'Portfolio Snapshot', + icon: 'gauge', + // NO objectName — this is what makes it an object-less ('global') action. + type: 'script', + body: { + language: 'js', + source: + "var accounts = await ctx.api.object('showcase_account').count({});" + + "var projects = await ctx.api.object('showcase_project').count({});" + + "var invoices = await ctx.api.object('showcase_invoice').count({});" + + "return { ok: true, scope: 'global', accounts: accounts, projects: projects, invoices: invoices };", + capabilities: ['api.read'], + }, + successMessage: 'Portfolio snapshot taken.', + locations: ['global_nav'], + refreshAfter: false, + ai: { + exposed: true, + description: + 'Count the accounts, projects and invoices in this workspace. Use when the user asks how big the portfolio is, or for a quick health snapshot across objects.', + }, +}); + export const allActions = [ MarkDoneAction, OpenDocsAction, @@ -286,4 +335,5 @@ export const allActions = [ SubmitForSignoffAction, ActionParamGalleryAction, ArchiveTaskAction, + PortfolioSnapshotAction, ]; diff --git a/examples/app-showcase/test/actions.test.ts b/examples/app-showcase/test/actions.test.ts index 4fb1d150e5..5e144efa4f 100644 --- a/examples/app-showcase/test/actions.test.ts +++ b/examples/app-showcase/test/actions.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { actionBodyRunnerFactory, QuickJSScriptRunner } from '@objectstack/runtime'; -import { allActions, MarkDoneAction } from '../src/ui/actions/index.js'; +import { allActions, MarkDoneAction, PortfolioSnapshotAction } from '../src/ui/actions/index.js'; /** * Execution-path coverage for declared actions. @@ -84,3 +84,73 @@ describe('showcase actions — executability', () => { expect(handler).toBeUndefined(); }); }); + +/** + * The object-less ("global") action specimen — framework#3913. + * + * #3913 was filed because object-less actions were unreachable: registered + * under the canonical `'global'` key, looked up under `'*'`. Its follow-up then + * found that `POST /api/v1/actions//:action` — the empty-object-segment URL the + * issue was actually filed against — had no route registration at all. Both + * were fixed with **no live specimen**: the showcase is the "one specimen of + * everything" app and every action in it declared an `objectName`, so nothing + * exercised the object-less dispatch path end to end. + * + * These tests pin the two properties that make it a specimen. The first is the + * one that silently rots: adding an `objectName` to this action would still + * build, still pass `coverage.test.ts`, and still work — while quietly removing + * the app's only coverage of object-less dispatch. + */ +describe('showcase actions — the object-less (`global`) specimen', () => { + const runner = new QuickJSScriptRunner(); + + it('declares no object, so it keys at `global` (framework#3913)', () => { + // This mirrors ObjectQLPlugin.actionObjectKey / AppPlugin's + // `action.object || 'global'`: neither field set → the 'global' bucket. + const a = PortfolioSnapshotAction as { objectName?: string; object?: string }; + expect(a.objectName).toBeUndefined(); + expect(a.object).toBeUndefined(); + + // ...and it is the ONLY one, so this test is what keeps the specimen alive. + const objectLess = allActions.filter((x) => { + const y = x as { objectName?: string; object?: string }; + return !y.objectName && !y.object; + }); + expect(objectLess.map((x) => x.name)).toEqual(['showcase_portfolio_snapshot']); + }); + + it('counts across several objects via the sandboxed body', async () => { + // An object-less action has no record and no single object to hang off — + // this body reads three, which is why it cannot be given an `objectName`. + const counts: Record = { + showcase_account: 15, + showcase_project: 5, + showcase_invoice: 13, + }; + const seen: string[] = []; + const ql = { + object: (object: string) => ({ + count: async () => { + seen.push(object); + return counts[object] ?? 0; + }, + }), + }; + + const factory = actionBodyRunnerFactory(runner, { ql, appId: 'showcase' }); + const handler = factory(PortfolioSnapshotAction as never); + expect(typeof handler).toBe('function'); + + // No recordId, no record — the object-less invocation shape. + const result = await handler!({ params: {}, user: { id: 'u1' } }); + + expect(seen).toEqual(['showcase_account', 'showcase_project', 'showcase_invoice']); + expect(result).toEqual({ + ok: true, + scope: 'global', + accounts: 15, + projects: 5, + invoices: 13, + }); + }); +}); From a683a2ade7e912d1f1f8634270a62c334655892c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:57:37 +0000 Subject: [PATCH 2/2] fix(showcase): translate the object-less action, restoring the i18n ratchet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript Type Check went red on `check:i18n-coverage`, not on types: examples/app-showcase/objectstack.config.ts: untranslated declared strings grew 456 -> 458 That gate freezes each example's untranslated-string debt and fails the moment it grows. The new action declares exactly two strings — `label` and `successMessage` — so the +2 is mine. Translated rather than ratcheted. Raising the baseline to 458 would have made the gate green by widening the debt it exists to prevent, and there is in-file precedent against that: `showcase_action_param_gallery`'s `p_assignee` was "translated at birth" for this exact reason (#3405). The keys go under the bundle's top-level `globalActions` slot, which is the ONLY place the resolver looks for an action declaring no `objectName` — keys for such an action under `objects.*._actions` are never read. That slot had zero users anywhere in the repo before this, which follows from the same gap #3913 left: no object-less action existed to translate. So the specimen now also covers the object-less translation path. Verified the key genuinely resolves rather than being silently ignored: with the name misspelled, `os lint` reports `translation-target-unknown` at `translations[0].en.globalActions.showcase_portfolio_snapshotX` with a "Did you mean showcase_portfolio_snapshot?" suggestion; spelled correctly it is clean. check-i18n-coverage back to OK (none new, baseline untouched at 456), `os lint` 0 errors, tsc clean, eslint clean, 60 tests pass, build green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F --- .../src/system/translations/index.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 1a3de0e90e..08fc9badee 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -138,6 +138,17 @@ export const ShowcaseTranslationBundle = { label: 'Field Zoo', pluralLabel: 'Field Zoo', }, }, + // The OBJECT-LESS action slot (framework#3913). `globalActions` is the only + // place the resolver looks for an action that declares no `objectName` — + // keys for such an action under `objects.*._actions` are never read. Before + // `showcase_portfolio_snapshot` the showcase had no object-less action, so + // this slot had no specimen anywhere in the repo. + globalActions: { + showcase_portfolio_snapshot: { + label: 'Portfolio Snapshot', + successMessage: 'Portfolio snapshot taken.', + }, + }, }, 'zh-CN': { objects: { @@ -286,5 +297,16 @@ export const ShowcaseTranslationBundle = { }, }, }, + // See the `en` block: object-less actions resolve ONLY through + // `globalActions`. Translated at birth for the same reason as + // `showcase_action_param_gallery.params.p_assignee` above — this example is + // ratcheted at its current untranslated count, so a new declared label that + // skips zh-CN widens the debt and fails `check-i18n-coverage`. + globalActions: { + showcase_portfolio_snapshot: { + label: '业务概览', + successMessage: '已生成业务概览。', + }, + }, }, };