Skip to content

Commit de01520

Browse files
authored
refactor(service-automation): deprecate non-canonical CRUD node config-key aliases (#2425)
1 parent 510f2f1 commit de01520

4 files changed

Lines changed: 194 additions & 8 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Deprecation shim for non-canonical flow-node `config` keys.
5+
*
6+
* `FlowNodeSchema.config` is an unconstrained `z.record(z.string(), z.unknown())`
7+
* (packages/spec/src/automation/flow.zod.ts), so the spec blesses *no* particular
8+
* key — the executor is the only thing that gives `config` a shape at runtime.
9+
* Historically several built-in executors quietly accepted a non-canonical alias
10+
* via `cfg.canonical ?? cfg.alias` (e.g. `object` for `objectName`, `filters` for
11+
* `filter`). That Postel's-law tolerance fossilizes the wrong shape into a
12+
* de-facto second contract and hides metadata-generation bugs: a flow authored
13+
* with the wrong key "just works" at runtime, so nothing ever flags it.
14+
*
15+
* The convergence (mirroring the `fields` / `fieldValues` decision) is to make
16+
* the canonical key the *single* contract and reject the wrong shape at
17+
* author/publish time:
18+
* • the cloud `graph-lint` gate rejects the alias when a flow is published, and
19+
* • the authoring sources (skills / example flows) only ever emit the canonical.
20+
*
21+
* graph-lint only runs at publish, so it cannot reach flows already stored in
22+
* prod that are never re-published. {@link readAliasedConfig} closes that gap for
23+
* the **deprecation window**: it keeps accepting the alias (so live flows keep
24+
* running) but emits a one-time `logger.warn` per alias steering the author to
25+
* the canonical key. Removal of the alias paths is tracked as a follow-up and
26+
* happens once the window has elapsed and graph-lint has been enforcing.
27+
*
28+
* @see crud-nodes.ts for the first call sites (`objectName`, `filter`).
29+
*/
30+
31+
/** One-time-warning ledger, keyed by `${nodeType}:${canonical}<-${alias}`. */
32+
const warnedAliases = new Set<string>();
33+
34+
/** Test-only: clear the one-time-warning ledger so each test starts fresh. */
35+
export function __resetAliasDeprecationWarnings(): void {
36+
warnedAliases.clear();
37+
}
38+
39+
/**
40+
* Read a node-config value by its **canonical** key, tolerating deprecated
41+
* aliases for one deprecation window.
42+
*
43+
* Returns the value under `canonical` when present; otherwise the first present
44+
* `alias` (warning once), otherwise `undefined`. "Present" means `!= null`, so
45+
* the fall-through matches the `cfg.canonical ?? cfg.alias` semantics it replaces
46+
* — callers keep applying their own default (`?? {}` / `?? ''`).
47+
*
48+
* @deprecated The alias paths exist only to keep already-stored flows running.
49+
* Author flows with the canonical key; the aliases will be removed.
50+
*/
51+
export function readAliasedConfig(
52+
cfg: Record<string, unknown>,
53+
nodeType: string,
54+
canonical: string,
55+
aliases: readonly string[],
56+
logger: { warn(message: string): void },
57+
): unknown {
58+
if (cfg[canonical] != null) return cfg[canonical];
59+
for (const alias of aliases) {
60+
if (cfg[alias] != null) {
61+
const key = `${nodeType}:${canonical}<-${alias}`;
62+
if (!warnedAliases.has(key)) {
63+
warnedAliases.add(key);
64+
logger.warn(
65+
`[${nodeType}] config key '${alias}' is a deprecated alias of '${canonical}'. ` +
66+
`Rename it to '${canonical}' — the alias still works but is deprecated, rejected at ` +
67+
`publish time by graph-lint, and will be removed in a future release.`,
68+
);
69+
}
70+
return cfg[alias];
71+
}
72+
}
73+
return undefined;
74+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Deprecation window for non-canonical `config` keys on the CRUD nodes
5+
* (`object` → `objectName`, `filters` → `filter`). The alias keeps working so
6+
* already-stored flows keep running, but emits a one-time `logger.warn` steering
7+
* the author to the canonical key. See config-aliases.ts.
8+
*/
9+
import { describe, it, expect, beforeEach } from 'vitest';
10+
import { AutomationEngine } from '../engine.js';
11+
import { registerCrudNodes } from './crud-nodes.js';
12+
import { __resetAliasDeprecationWarnings } from './config-aliases.js';
13+
14+
function silentLogger(): any {
15+
const l: any = { info() {}, warn() {}, error() {}, debug() {} };
16+
l.child = () => l;
17+
return l;
18+
}
19+
20+
function collectingLogger(warns: string[]): any {
21+
const l: any = { info() {}, warn(m: string) { warns.push(m); }, error() {}, debug() {} };
22+
l.child = () => l;
23+
return l;
24+
}
25+
26+
function fakeData() {
27+
const calls: Array<{ op: string; obj: string; opts?: any }> = [];
28+
const data: any = {
29+
async find(obj: string, opts: any) { calls.push({ op: 'find', obj, opts }); return [{ id: 'r1' }]; },
30+
async findOne(obj: string, opts: any) { calls.push({ op: 'findOne', obj, opts }); return { id: 'r1' }; },
31+
async insert(obj: string, fields: any) { calls.push({ op: 'insert', obj, opts: { fields } }); return { id: `${obj}_1`, ...fields }; },
32+
async update(obj: string, fields: any, opts: any) { calls.push({ op: 'update', obj, opts: { ...opts, fields } }); return { ok: true }; },
33+
async delete(obj: string, opts: any) { calls.push({ op: 'delete', obj, opts }); return { ok: true }; },
34+
};
35+
return { data, calls };
36+
}
37+
38+
const ctxWith = (data: any, logger: any): any => ({
39+
logger,
40+
getService: (n: string) => (n === 'data' ? data : undefined),
41+
});
42+
43+
function getRecordFlow(config: Record<string, unknown>) {
44+
return {
45+
name: 'gr', label: 'G', type: 'autolaunched',
46+
nodes: [
47+
{ id: 'start', type: 'start', label: 'Start' },
48+
{ id: 'g', type: 'get_record', label: 'Get', config },
49+
{ id: 'end', type: 'end', label: 'End' },
50+
],
51+
edges: [
52+
{ id: 'e1', source: 'start', target: 'g' },
53+
{ id: 'e2', source: 'g', target: 'end' },
54+
],
55+
} as any;
56+
}
57+
58+
describe('CRUD config-key alias deprecation (object→objectName, filters→filter)', () => {
59+
beforeEach(() => __resetAliasDeprecationWarnings());
60+
61+
it('still resolves the deprecated `object` + `filters` aliases at runtime', async () => {
62+
const engine = new AutomationEngine(silentLogger());
63+
const { data, calls } = fakeData();
64+
const warns: string[] = [];
65+
registerCrudNodes(engine, ctxWith(data, collectingLogger(warns)));
66+
67+
engine.registerFlow('gr', getRecordFlow({ object: 'crm_lead', filters: { id: 'L1' }, outputVariable: 'lead' }));
68+
const res = await engine.execute('gr');
69+
70+
expect(res.success).toBe(true);
71+
// The alias values reached the data engine unchanged.
72+
expect(calls).toHaveLength(1);
73+
expect(calls[0].obj).toBe('crm_lead');
74+
expect(calls[0].opts.where).toEqual({ id: 'L1' });
75+
});
76+
77+
it('warns once per alias, naming the canonical key', async () => {
78+
const engine = new AutomationEngine(silentLogger());
79+
const { data } = fakeData();
80+
const warns: string[] = [];
81+
registerCrudNodes(engine, ctxWith(data, collectingLogger(warns)));
82+
83+
engine.registerFlow('gr', getRecordFlow({ object: 'crm_lead', filters: { id: 'L1' } }));
84+
await engine.execute('gr');
85+
86+
const objectWarn = warns.find((w) => w.includes("'object'") && w.includes("'objectName'"));
87+
const filterWarn = warns.find((w) => w.includes("'filters'") && w.includes("'filter'"));
88+
expect(objectWarn).toBeTruthy();
89+
expect(filterWarn).toBeTruthy();
90+
91+
// Second run in the same process must NOT warn again (one-time per alias).
92+
const before = warns.length;
93+
await engine.execute('gr');
94+
expect(warns.length).toBe(before);
95+
});
96+
97+
it('does NOT warn when the canonical keys are used', async () => {
98+
const engine = new AutomationEngine(silentLogger());
99+
const { data } = fakeData();
100+
const warns: string[] = [];
101+
registerCrudNodes(engine, ctxWith(data, collectingLogger(warns)));
102+
103+
engine.registerFlow('gr', getRecordFlow({ objectName: 'crm_lead', filter: { id: 'L1' }, outputVariable: 'lead' }));
104+
const res = await engine.execute('gr');
105+
106+
expect(res.success).toBe(true);
107+
expect(warns).toHaveLength(0);
108+
});
109+
});

packages/services/service-automation/src/builtin/crud-nodes.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { defineActionDescriptor } from '@objectstack/spec/automation';
55
import type { IDataEngine } from '@objectstack/spec/contracts';
66
import type { AutomationEngine } from '../engine.js';
77
import { interpolate } from './template.js';
8+
import { readAliasedConfig } from './config-aliases.js';
89
import { resolveRunDataContext } from '../runtime-identity.js';
910

1011
/**
@@ -43,10 +44,10 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
4344
}),
4445
async execute(node, variables, context) {
4546
const cfg = (node.config ?? {}) as Record<string, unknown>;
46-
const objectName = String(cfg.objectName ?? cfg.object ?? '');
47+
const objectName = String(readAliasedConfig(cfg, 'get_record', 'objectName', ['object'], ctx.logger) ?? '');
4748
if (!objectName) return { success: false, error: 'get_record: objectName required' };
4849

49-
const filter = interpolate(cfg.filter ?? cfg.filters ?? {}, variables, context) as Record<string, unknown>;
50+
const filter = interpolate(readAliasedConfig(cfg, 'get_record', 'filter', ['filters'], ctx.logger) ?? {}, variables, context) as Record<string, unknown>;
5051
const fields = cfg.fields as string[] | undefined;
5152
const limit = typeof cfg.limit === 'number' ? cfg.limit : undefined;
5253
const outputVariable = cfg.outputVariable as string | undefined;
@@ -85,7 +86,7 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
8586
}),
8687
async execute(node, variables, context) {
8788
const cfg = (node.config ?? {}) as Record<string, unknown>;
88-
const objectName = String(cfg.objectName ?? cfg.object ?? '');
89+
const objectName = String(readAliasedConfig(cfg, 'create_record', 'objectName', ['object'], ctx.logger) ?? '');
8990
if (!objectName) return { success: false, error: 'create_record: objectName required' };
9091

9192
const fields = interpolate(cfg.fields ?? {}, variables, context) as Record<string, unknown>;
@@ -134,10 +135,12 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
134135
}),
135136
async execute(node, variables, context) {
136137
const cfg = (node.config ?? {}) as Record<string, unknown>;
137-
const objectName = String(cfg.objectName ?? cfg.object ?? '');
138+
const objectName = String(readAliasedConfig(cfg, 'update_record', 'objectName', ['object'], ctx.logger) ?? '');
138139
if (!objectName) return { success: false, error: 'update_record: objectName required' };
139140

140-
const filter = interpolate(cfg.filter ?? cfg.filters ?? {}, variables, context) as Record<string, unknown>;
141+
const filter = interpolate(readAliasedConfig(cfg, 'update_record', 'filter', ['filters'], ctx.logger) ?? {}, variables, context) as Record<string, unknown>;
142+
// `fields` is the single canonical write-map key — no alias (the wrong key
143+
// `fieldValues` is corrected at the authoring source + rejected by graph-lint).
141144
const fields = interpolate(cfg.fields ?? {}, variables, context) as Record<string, unknown>;
142145

143146
const data = getData();
@@ -167,10 +170,10 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
167170
}),
168171
async execute(node, variables, context) {
169172
const cfg = (node.config ?? {}) as Record<string, unknown>;
170-
const objectName = String(cfg.objectName ?? cfg.object ?? '');
173+
const objectName = String(readAliasedConfig(cfg, 'delete_record', 'objectName', ['object'], ctx.logger) ?? '');
171174
if (!objectName) return { success: false, error: 'delete_record: objectName required' };
172175

173-
const filter = interpolate(cfg.filter ?? cfg.filters ?? {}, variables, context) as Record<string, unknown>;
176+
const filter = interpolate(readAliasedConfig(cfg, 'delete_record', 'filter', ['filters'], ctx.logger) ?? {}, variables, context) as Record<string, unknown>;
174177

175178
const data = getData();
176179
if (!data) return { success: true };

skills/objectstack-automation/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ is one diagram a reviewer (or AI) can read end-to-end.
278278
},
279279
},
280280
{ id: 'mark_won', type: 'update_record',
281-
config: { object: 'opportunity', filter: { id: '{record.id}' }, fields: { stage: 'closed_won' } } },
281+
config: { objectName: 'opportunity', filter: { id: '{record.id}' }, fields: { stage: 'closed_won' } } },
282282
{ id: 'approved', type: 'end' },
283283
{ id: 'rejected', type: 'end' },
284284
],

0 commit comments

Comments
 (0)