diff --git a/.changeset/converge-runtime-global-action-key-literals.md b/.changeset/converge-runtime-global-action-key-literals.md new file mode 100644 index 0000000000..beea7f01ec --- /dev/null +++ b/.changeset/converge-runtime-global-action-key-literals.md @@ -0,0 +1,47 @@ +--- +"@objectstack/runtime": patch +--- + +refactor(runtime): spell the object-less action key as `GLOBAL_ACTION_OBJECT_KEY` in `action-execution.ts` (#14678) + +`GLOBAL_ACTION_OBJECT_KEY` exists so the object-less action-registration key is +written once. #14422 converged the owner-key LADDER and the ObjectQL plugin's +copy of it; three bare `'global'` spellings elsewhere in +`packages/runtime/src/action-execution.ts` were never in that card's path, +because the runtime fence it built was a re-export plus a delegating alias. +This converges those three. The constant was already imported in the file. + +No behaviour moves — the constant is `'global'`, so every site is equal in +value before and after. That equality is the entire defect: it is what made the +three invisible to every test in the repo, and what would have let them part +from the constant in silence the day its value changes. + +- `seedFlowActionParams` — a live comparison (`objectName !== 'global'`) that + decides whether an object-derived `Id` param key is seeded. The one + site where a drifted literal would change what an action body receives. +- `enforceActionParams` — the warn-once dedup key, which is also interpolated + into the operator-facing `[action-params] : …` line. Converged rather + than left: the argument for a literal here is that a log key must never fail + to render, and that argument does not survive contact with the fact that + `GLOBAL_ACTION_OBJECT_KEY` is a module-scope `const string` already imported + into this file — it cannot fail to render either. What a drift there would + actually cost is an operator grepping logs by the key the engine now uses and + silently missing these lines. +- `collectActionDeclarations`'s docblock, which carried a second defect + independent of the literal: it called the key "the `'global'` wildcard", + contradicting `action-governance.ts` ("an exact-string `Map` lookup with no + wildcard semantics"). It is now the phrasing the sibling docblock 48 lines + below it already used — "the object-less `GLOBAL_ACTION_OBJECT_KEY`" — so the + correction is copied from the file's own converged prose rather than invented. + +`patch`, not `skip-changeset`: `packages/runtime` publishes `dist`, which is +built from this source, so the emitted bytes move even though the behaviour +does not. Nothing reaches the published entry — `action-execution.ts` is not +re-exported from `packages/runtime/src/index.ts` and no export, signature or +type changed here — which is what keeps it below `minor`. + +The docblock that promised the lockstep is joined by a weld that enforces it: +`action-owner-key-single-source.test.ts` gains a half C that reads +`action-execution.ts` and fails if any quote spelling of the key is written out +by hand again. The forbidden spelling is DERIVED from the constant rather than +hard-coded, so the guard is not itself a fourth copy of the literal it forbids. diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index b049a0e537..122fbe6bed 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -638,7 +638,7 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps, if (rowId != null) { const keys = new Set(['recordId']); - if (objectName && objectName !== 'global') { + if (objectName && objectName !== GLOBAL_ACTION_OBJECT_KEY) { keys.add(`${objectName.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())}Id`); } if (typeof action?.recordIdParam === 'string' && action.recordIdParam) { @@ -1013,7 +1013,7 @@ export function enforceActionParams(deps: ActionExecutionDeps, if (!laxActionParams()) { return `Invalid action params: ${summary}`; } - const key = `${where.objectName ?? 'global'}/${where.actionName ?? action?.name ?? 'action'}`; + const key = `${where.objectName ?? GLOBAL_ACTION_OBJECT_KEY}/${where.actionName ?? action?.name ?? 'action'}`; warnActionParamsOnce( key, `[action-params] ${key}: ${summary} — accepted because ` + @@ -1487,7 +1487,7 @@ export async function resolveActionByName(deps: ActionExecutionDeps, * engine executes since #2608 (`resyncAuthoredActions`) but that never * appear inside any object definition. Their owning object follows the * same convention as the engine registration key (`objectName` field, - * legacy `object` field, else the `'global'` wildcard). + * legacy `object` field, else the object-less `GLOBAL_ACTION_OBJECT_KEY`). * * On a key clash (`objectName:name`) the object-embedded declaration wins, * mirroring the execution layer's artifact-wins rule — `resyncAuthoredActions` diff --git a/packages/runtime/src/action-owner-key-single-source.test.ts b/packages/runtime/src/action-owner-key-single-source.test.ts index b4db49e92e..53aab30cc5 100644 --- a/packages/runtime/src/action-owner-key-single-source.test.ts +++ b/packages/runtime/src/action-owner-key-single-source.test.ts @@ -25,6 +25,15 @@ * copy: a byte-identical second spelling passes every assertion in half A. Half * B reads this package's own source and fails if the ladder grows a second * body here. + * + * Half C closes the same hole one level down (#14678). #14422 converged the + * LADDER, and the runtime kept three bare `'global'` spellings elsewhere in + * `action-execution.ts` that the ladder check could not see: a live comparison + * in `seedFlowActionParams`, a warn-once log key in `enforceActionParams`, and + * a docblock. All three were equal in value and invisible to every test in the + * repo, which is the whole shape #14422 was filed to remove — so the same + * convergence needed the same weld, or the next reader re-inlines one and + * nothing says so. */ import { readFileSync } from 'node:fs'; @@ -89,6 +98,25 @@ function readActionExecutionSource(): string { return readFileSync(join(here, 'action-execution.ts'), 'utf8'); } +/** + * Quote spellings of the object-less key as a bare literal, DERIVED from the + * constant rather than hard-coded. + * + * Deriving it is the point, not a flourish. A hard-coded `'global'` here would + * be a fourth copy of the very literal this file exists to forbid, and it + * would go stale in the same silence the day the constant moves. Derived, the + * guard follows the constant: whatever `GLOBAL_ACTION_OBJECT_KEY` becomes, + * that is the spelling `action-execution.ts` may not write out by hand. The + * re-inlining it catches is caught at the moment it happens, while the two + * spellings are still equal — which is the only moment a reader can tell they + * were ever meant to be one thing. + */ +const BARE_LITERALS: readonly string[] = [ + `'${GLOBAL_ACTION_OBJECT_KEY}'`, + `"${GLOBAL_ACTION_OBJECT_KEY}"`, + `\`${GLOBAL_ACTION_OBJECT_KEY}\``, +]; + describe('standalone-action owner key — half B: one spelling (#14422)', () => { it('keeps no ladder body of its own in action-execution.ts', () => { const src = readActionExecutionSource(); @@ -112,3 +140,27 @@ describe('standalone-action owner key — half B: one spelling (#14422)', () => expect(body[1].trim()).toBe('return standaloneActionOwnerKey(action);'); }); }); + +describe('standalone-action owner key — half C: no bare literal (#14678)', () => { + it('spells the object-less key as the CONSTANT everywhere in action-execution.ts', () => { + const src = readActionExecutionSource(); + + // Anti-vacuity, twice over. An empty read, or a file that does not + // import the constant at all, would make every negative below pass for + // exactly the wrong reason — the can-never-fail property this whole + // file was written to replace. Both controls are positive assertions + // against text the converged file must carry. + expect(src).toContain('GLOBAL_ACTION_OBJECT_KEY'); + expect(src).toContain('objectName !== GLOBAL_ACTION_OBJECT_KEY'); + + for (const literal of BARE_LITERALS) { + expect( + src.includes(literal), + `action-execution.ts writes the object-less action key as the bare literal ` + + `${literal}. It is equal in value to GLOBAL_ACTION_OBJECT_KEY today and parts ` + + `from it in silence the day the constant moves (#14422, #14678). Import the ` + + `constant — this file already does — and compare or interpolate that instead.`, + ).toBe(false); + } + }); +});