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
12 changes: 12 additions & 0 deletions .changeset/28163-stored-connector-input-listed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/spec': patch
---

fix(spec): `os migrate meta --stored` lists a stored `connector_action` node whose `connectorConfig.input` carries a `{…}` token, which boot refuses

Clause-②: no
Metadata-protocol: none

- **Before.** The stored-shape listing judged a flow node's value slots with the node's `config` alone, so it never read the value slot rooted in a `connector_action` node's `connectorConfig.input`. A stored flow with `input: { text: '{record.name}' }` was refused by `registerFlow` at boot (skipped with a `warn`, its trigger not armed), yet the preview listed nothing for it.
- **Now.** The listing hands the value-slot judge the whole node, as `registerFlow` and `objectstack validate` do, so it lists such a value as a TODO under the `connector-action-input-value-slot-judged` entry, by flow, node and path (`connectorConfig.input.text`), with the value's CEL spelling. A loose `config.input` the conversions lift onto `connectorConfig` is listed at its lifted path. Nothing is rewritten.
- **Unchanged.** A `config` value slot is listed under `flow-value-slot-template-dialect-refused`, as before. A CEL value envelope or a literal in `connectorConfig.input` is not listed.
90 changes: 90 additions & 0 deletions packages/spec/src/conversions/refused-shapes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { describe, expect, it } from 'vitest';

import { FLOW_NODE_SIBLING_EXPRESSION_BLOCKS } from '../automation/flow-node-expression-paths.js';
import { flowNodeValueTemplateRefusals } from '../automation/flow-value-slot-template.js';
import { ObjectSchema } from '../data/object.zod.js';
import { MIGRATIONS_BY_MAJOR } from '../migrations/registry.js';
Expand Down Expand Up @@ -66,6 +67,7 @@ describe('the refused-shape list — registered against the migration chain', ()
'cbp-master-relation-tiers-retired',
'view-data-api-provider-retired',
'flow-value-slot-template-dialect-refused',
'connector-action-input-value-slot-judged',
'object-external-binding-managed-datasource-refused',
'visibility-predicate-has-argument-refused',
'object-duplicate-action-name-refused',
Expand Down Expand Up @@ -364,6 +366,94 @@ describe('#23269 — a stored flow whose CRUD filter operand is a {…} token is
});
});

// ── #28163 ────────────────────────────────────────────────────────────────

const connectorNode = (id: string, input: unknown) => ({
id,
type: 'connector_action',
label: id,
connectorConfig: { connectorId: 'helpdesk', actionId: 'open_ticket', input },
});

describe('#28163 — a stored connector_action whose connectorConfig.input carries a {…} token is listed by flow and node', () => {
it('one TODO under the connector input entry, at connectorConfig.input.<key>; the row is not rewritten', () => {
const row = flowOf('fd_connector', [connectorNode('ticket', { text: '{record.name}', queue: 'support' })]);
const before = JSON.stringify(row);
const { item, todos } = storedTodos('flow', row);
expect(JSON.stringify(item)).toBe(before);
expect(todos).toHaveLength(1);
const todo = todos[0]!;
expect(todo.conversionId).toBe('connector-action-input-value-slot-judged');
expect(todo.path).toBe('flows[0].nodes[1].connectorConfig.input.text');
expect(todo.from).toBe(JSON.stringify('{record.name}'));
expect(todo.reason).toContain(
"Flow `fd_connector`, node 'ticket' (connector_action), connector_action input value at connectorConfig.input.text.",
);
expect(todo.reason).toContain('registerFlow refuses the flow: it is not registered at boot and never runs');
expect(todo.reason).toContain("{ dialect: 'cel', source: 'record.name' }");
});

it('reaches a value nested in the input map, and a node inside a structured region', () => {
const flow = flowOf('fd_connector_loop', [{
id: 'each', type: 'loop', label: 'Each',
config: {
collection: { dialect: 'cel', source: 'items' },
body: { nodes: [connectorNode('inner', { tags: ['ok', '{item.tag}'] })], edges: [] },
},
}]);
const todos: ConversionTodoNotice[] = [];
applyConversionsToFlow(flow, { includeRetired: true, onTodo: (t) => todos.push(t) });
expect(todos.map((t) => [t.conversionId, t.path])).toEqual([
['connector-action-input-value-slot-judged', 'flows[0].nodes[1].config.body.nodes[0].connectorConfig.input.tags[1]'],
]);
});

it('judges the row the conversions produced: a loose config.input the chain lifts is listed at connectorConfig', () => {
const row = flowOf('fd_connector_loose', [{
id: 'loose', type: 'connector_action', label: 'Loose',
config: { connectorId: 'helpdesk', actionId: 'open_ticket', input: { text: '{record.name}' } },
}]);
const { todos } = storedTodos('flow', row);
expect(todos.map((t) => [t.conversionId, t.path])).toEqual([
['connector-action-input-value-slot-judged', 'flows[0].nodes[1].connectorConfig.input.text'],
]);
});

it.each([
['a CEL envelope', { text: { dialect: 'cel', source: 'record.name' } }],
['a literal', { text: 'Ticket', queue: 'support' }],
['no input', undefined],
])('a connector_action already on protocol (%s) is not listed', (_label, input) => {
const flow = flowOf('fd_connector_ok', [connectorNode('ticket', input)]);
const todos: ConversionTodoNotice[] = [];
applyConversionsToFlow(flow, { includeRetired: true, onTodo: (t) => todos.push(t) });
expect(todos).toEqual([]);
});

it('agrees with the refusing door: each site is one the value-slot judge refuses when handed the node, and no other', () => {
const nodes: Array<Record<string, any>> = [
connectorNode('a', { text: '{record.name}', id: { dialect: 'cel', source: 'record.id' } }),
crudNode('b', 'get_record', { id: '{record.id}' }),
connectorNode('c', { note: 'plain' }),
];
const { todos } = todosOf({ flows: [flowOf('fd_connector_mixed', nodes)] });
// The door's own call — `registerFlow` and `objectstack validate` hand the node.
const judged = nodes.flatMap((n, i) =>
flowNodeValueTemplateRefusals(n.type, n.config, n).map((r) => `flows[0].nodes[${i + 1}].${r.block}.${r.path}`));
// Report order is detector order, so the two are compared as sets.
expect(todos.map((t) => t.path).sort()).toEqual([...judged].sort());
expect(todos.map((t) => [t.conversionId, t.path])).toEqual([
['flow-value-slot-template-dialect-refused', 'flows[0].nodes[2].config.filter.id'],
['connector-action-input-value-slot-judged', 'flows[0].nodes[1].connectorConfig.input.text'],
]);
});

it('the two flow detectors cover every block the ledger roots a value slot in', () => {
// A new sibling block needs a detector here (and its D3 entry) before its refusals can be listed.
expect(['config', ...Object.keys(FLOW_NODE_SIBLING_EXPRESSION_BLOCKS)]).toEqual(['config', 'connectorConfig']);
});
});

// ── #23317 ────────────────────────────────────────────────────────────────

describe('#23317 — a stored page whose component binds the retired API source in its `data` prop is listed by page and component', () => {
Expand Down
93 changes: 72 additions & 21 deletions packages/spec/src/conversions/refused-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
* component-props gate parses at the CLI doors and at the runtime save
* door — and the value-slot judge
* `AutomationEngine.registerFlow` and `objectstack validate` run
* ({@link flowNodeValueTemplateRefusals}), and the `external` binding judge
* ({@link flowNodeValueTemplateRefusals}), handed the whole node as those
* doors hand it, so a sibling block's slot is judged too, and the
* `external` binding judge
* the object refinement and `defineStack` share
* ({@link externalBindingDatasourceRefusal}), and the one-name-per-action
* judge of the object refinement ({@link duplicateActionNames}). A site is listed here exactly
Expand All @@ -54,7 +56,10 @@
* The TODO notices it produces are the published shape.
*/

import { flowNodeValueTemplateRefusals } from '../automation/flow-value-slot-template.js';
import {
flowNodeValueTemplateRefusals,
type FlowNodeValueTemplateRefusal,
} from '../automation/flow-value-slot-template.js';
import {
controlledByParentMasterRefusal,
isControlledByParentMasterField,
Expand Down Expand Up @@ -391,30 +396,75 @@ const viewDataApiProvider: RefusedStoredShape = {

// ── #23269: a flow value slot carrying a `{…}` token, a CRUD filter operand among them ──

/**
* Hand every value-slot refusal of every node of every stored flow to `visit`,
* with the flow's listed name and the node's own path. The judge is handed the
* node itself (#28163), as `registerFlow` and `objectstack validate` hand it,
* so a slot the ledger roots in a sibling block — a `connector_action` node's
* `connectorConfig.input` — is judged here exactly when those doors judge it.
* Each refusal names its block, and the two detectors below split the blocks
* by the D3 entry that refuses each: `config` and `connectorConfig`, which
* `refused-shapes.test.ts` holds equal to the blocks the ledger declares, so a
* new sibling block cannot go unlisted.
*/
function forEachFlowValueSlotRefusal(
stack: Dict,
visit: (flowName: string, node: Dict & { type: string }, path: string, refusal: FlowNodeValueTemplateRefusal) => void,
): void {
const flows = stack.flows;
if (!Array.isArray(flows)) return;
flows.forEach((flow, fi) => {
if (!isDict(flow) || !Array.isArray(flow.nodes)) return;
const flowName = typeof flow.name === 'string' ? flow.name : `flows[${fi}]`;
mapFlowNodeList(flow.nodes, `flows[${fi}].nodes`, (node, path) => {
if (typeof node.type !== 'string') return node;
const typed = node as Dict & { type: string };
for (const refusal of flowNodeValueTemplateRefusals(typed.type, node.config, node)) {
visit(flowName, typed, path, refusal);
}
return node;
});
});
}

const flowValueSlotTemplateToken: RefusedStoredShape = {
id: 'flow-value-slot-template-dialect-refused',
toMajor: 18,
surface: 'flows[].nodes[].config — a value slot (a get_record, update_record or delete_record filter operand among them)',
detect(stack, report) {
const flows = stack.flows;
if (!Array.isArray(flows)) return;
flows.forEach((flow, fi) => {
if (!isDict(flow) || !Array.isArray(flow.nodes)) return;
const flowName = typeof flow.name === 'string' ? flow.name : `flows[${fi}]`;
mapFlowNodeList(flow.nodes, `flows[${fi}].nodes`, (node, path) => {
if (typeof node.type !== 'string') return node;
for (const refusal of flowNodeValueTemplateRefusals(node.type, node.config)) {
report({
path: `${path}.config.${refusal.path}`,
from: JSON.stringify(refusal.source),
reason: `Flow \`${flowName}\`, node '${String(node.id)}' (${node.type}), ${refusal.label} at `
+ `config.${refusal.path}. No lossless rewrite: the template resolved a token CEL reads `
+ 'differently, so the upgrade does not convert it. Left as stored, registerFlow refuses the flow: '
+ 'it is not registered at boot and never runs. '
+ refusal.message,
});
}
return node;
forEachFlowValueSlotRefusal(stack, (flowName, node, path, refusal) => {
if (refusal.block !== 'config') return;
report({
path: `${path}.config.${refusal.path}`,
from: JSON.stringify(refusal.source),
reason: `Flow \`${flowName}\`, node '${String(node.id)}' (${node.type}), ${refusal.label} at `
+ `config.${refusal.path}. No lossless rewrite: the template resolved a token CEL reads `
+ 'differently, so the upgrade does not convert it. Left as stored, registerFlow refuses the flow: '
+ 'it is not registered at boot and never runs. '
+ refusal.message,
});
});
},
};

// ── #27968: a connector_action node's connectorConfig.input value carrying a `{…}` token ──

const connectorActionInputTemplateToken: RefusedStoredShape = {
id: 'connector-action-input-value-slot-judged',
toMajor: 18,
surface: 'flows[].nodes[].connectorConfig.input — a value of a connector_action node\'s input map',
detect(stack, report) {
forEachFlowValueSlotRefusal(stack, (flowName, node, path, refusal) => {
if (refusal.block !== 'connectorConfig') return;
const at = `${refusal.block}.${refusal.path}`;
report({
path: `${path}.${at}`,
from: JSON.stringify(refusal.source),
reason: `Flow \`${flowName}\`, node '${String(node.id)}' (${node.type}), ${refusal.label} at ${at}. `
+ 'No lossless rewrite: the action was handed the token\'s text as written, and no token has a '
+ 'lossless CEL spelling, so the upgrade does not convert it. Left as stored, registerFlow refuses the '
+ 'flow: it is not registered at boot and never runs. '
+ refusal.message,
});
});
},
Expand Down Expand Up @@ -513,6 +563,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [
controlledByParentWithoutOneMaster,
viewDataApiProvider,
flowValueSlotTemplateToken,
connectorActionInputTemplateToken,
externalBindingOnManagedDatasource,
visibilityHasArgument,
objectDuplicateActionName,
Expand Down
Loading