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
47 changes: 47 additions & 0 deletions .changeset/converge-runtime-global-action-key-literals.md
Original file line number Diff line number Diff line change
@@ -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 `<object>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] <key>: …` 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.
6 changes: 3 additions & 3 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ export function seedFlowActionParams(_deps: ActionExecutionDeps,

if (rowId != null) {
const keys = new Set<string>(['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) {
Expand Down Expand Up @@ -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 ` +
Expand Down Expand Up @@ -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`
Expand Down
52 changes: 52 additions & 0 deletions packages/runtime/src/action-owner-key-single-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
});
});
Loading